From 6ea4e84949a5ec5f0952f436c83cfff20e6dbb28 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Thu, 9 Apr 2026 06:00:59 +0900 Subject: [PATCH] Enhance Discord bot and HA gateway with improved structure and documentation - Added detailed comments to clarify the purpose of various functions and types in the Discord bot and HA gateway. - Introduced new methods in the CommandApp for handling light and switch operations, including HandleLightOn, HandleLightOff, HandleLightToggle, and their respective autocomplete functions. - Updated the HAClient interface to include methods for fetching states and calling services, enhancing the interaction with Home Assistant. - Improved the structure of entity and light domain models to include additional attributes and clearer documentation. - Implemented logging enhancements in both the Discord bot and HA gateway to ensure better traceability and context in logs. - Refactored the configuration loading process to streamline environment variable handling and defaults. - Stubbed out switch control methods in the gRPC adapter, indicating future implementation plans. - Enhanced telemetry setup to ensure proper initialization and shutdown procedures for observability. --- README.md | 544 ++++++------------ discord-bot/cmd/bot/main.go | 5 + .../adapters/primary/discord/handler.go | 14 + .../adapters/primary/discord/register.go | 3 + .../adapters/secondary/gateway/client.go | 10 + discord-bot/internal/app/command.go | 15 + discord-bot/internal/config/config.go | 3 + discord-bot/internal/core/ports/driven/ha.go | 39 +- discord-bot/internal/logger/logger.go | 3 + discord-bot/internal/telemetry/telemetry.go | 2 + ha-gateway/cmd/gateway/main.go | 5 + .../internal/adapters/primary/grpc/entity.go | 3 + .../internal/adapters/primary/grpc/event.go | 4 +- .../adapters/primary/grpc/interceptor.go | 7 + .../internal/adapters/primary/grpc/light.go | 5 + .../internal/adapters/primary/grpc/mapping.go | 6 + .../internal/adapters/primary/grpc/switch.go | 11 +- .../internal/adapters/secondary/ha/client.go | 9 + .../adapters/secondary/ha/websocket.go | 8 +- ha-gateway/internal/app/entity.go | 5 + ha-gateway/internal/app/light.go | 9 + ha-gateway/internal/app/switch.go | 4 + ha-gateway/internal/config/config.go | 3 + ha-gateway/internal/core/domain/entity.go | 15 +- ha-gateway/internal/core/domain/light.go | 54 +- ha-gateway/internal/core/domain/switch.go | 11 +- ha-gateway/internal/core/ports/driven/ha.go | 14 +- .../internal/core/ports/driving/entity.go | 2 + .../internal/core/ports/driving/light.go | 5 + .../internal/core/ports/driving/switch.go | 2 + ha-gateway/internal/logger/logger.go | 3 + ha-gateway/internal/telemetry/telemetry.go | 2 + 32 files changed, 404 insertions(+), 421 deletions(-) diff --git a/README.md b/README.md index eef3fea..45dc4d5 100644 --- a/README.md +++ b/README.md @@ -1,422 +1,210 @@ -# home-service +# home-services -This repo is a small Home Assistant control platform built around gRPC. +`home-services` is a Go mono-repo for internal home-control services built +around a gRPC gateway for Home Assistant. Discord and Alexa clients are meant +to talk to the gateway instead of integrating with Home Assistant directly. -There are three important pieces: +## Architecture -- `proto/`: the API contract, written in protobuf -- `gen/`: generated Go code from that contract -- `ha-gateway/`: the gRPC server that talks to Home Assistant -- `discord-bot/`: a Discord client that talks to `ha-gateway` +The repo uses hexagonal architecture: -If you are new to gRPC, the most important idea is: +- the core domain is plain Go data and interfaces +- adapters at the edges handle gRPC, HTTP, Discord, telemetry, and logging +- dependencies point inward only -1. define the API once in `.proto` files -2. generate client/server code from that definition -3. let all services use the same generated types so they agree on request and response shape +That means the business logic does not need to know about Discord, protobuf +transport details, or Home Assistant HTTP specifics. -## What Each Folder Does +### Service relationship ```text -. -├── proto/ # Source protobuf service and message definitions -├── gen/ # Generated Go code from proto/ -├── ha-gateway/ # gRPC server + Home Assistant REST adapter -├── discord-bot/ # Discord slash-command app + gRPC client to ha-gateway -├── buf.yaml # Buf module config -├── buf.gen.yaml # Buf code generation config -└── go.work # Go workspace linking all local modules +Discord bot ->\ + \ + -> ha-gateway -> Home Assistant + / +Alexa bridge ->/ ``` -## Why `gen/` Exists +- `discord-bot` is an internal gRPC client of `ha-gateway` +- `alexa-bridge` is planned as another internal gRPC client of `ha-gateway` +- `ha-gateway` is the only service that talks to Home Assistant directly -`gen/` is the bridge between your `.proto` files and your Go services. +### Proto-first design -The `.proto` files in `proto/ha/v1/` are the source of truth. They define: +All service contracts are defined in `proto/ha/v1/`. Generated Go code is +committed under `gen/` and used by both clients and servers. -- service names like `LightService` and `SwitchService` -- RPC methods like `ListLights`, `TurnOn`, and `ListSwitches` -- message schemas like `TurnOnRequest`, `LightEntity`, and `SwitchEntity` +This gives every service the same: -Those `.proto` files are not directly used by Go at runtime. They are compiled -into Go source files in `gen/ha/v1/`, such as: +- RPC method names +- request and response message types +- protobuf serialization rules +- gRPC client/server bindings -- `light.pb.go` -- `light_grpc.pb.go` -- `switch.pb.go` -- `switch_grpc.pb.go` +## Services -That generated code gives you: +### ha-gateway -- Go structs for protobuf messages -- gRPC client interfaces for callers -- gRPC server interfaces and registration helpers for servers +`ha-gateway` is the single internal gRPC gateway to Home Assistant. It should +not be exposed publicly. -In this repo: +- Port: `50051` +- Deployment model: internal-only, typically ClusterIP +- Implemented: + - `EntityService`: `GetState`, `ListStates` + - `LightService`: `TurnOn`, `TurnOff`, `Toggle`, `ListLights` + - `SwitchService`: `ListSwitches` +- Stubbed: + - `SwitchService`: `TurnOn`, `TurnOff`, `Toggle` + - `EventService` +- Observability: + - structured logging via `slog` + - `LOG_FORMAT=json` is suitable for production log pipelines + - `LOG_FORMAT=text` is more readable locally + - OpenTelemetry traces and metrics export over OTLP gRPC -- `discord-bot` imports `gitea.nik4nao.com/nik/home-services/gen/ha/v1` to get - generated gRPC clients like `hav1.LightServiceClient` -- `ha-gateway` imports the same package to register generated gRPC servers like - `hav1.RegisterLightServiceServer(...)` +Config: -Without `gen/`, both services would need to hand-write and manually keep in -sync: - -- request/response structs -- method names -- serialization logic -- client/server glue - -That is exactly what gRPC code generation is meant to remove. - -## High-Level Data Flow - -The request flow for a Discord command looks like this: - -```text -Discord user - -> discord-bot primary adapter - -> discord-bot app layer - -> discord-bot gRPC client adapter - -> generated gRPC client code from gen/ - -> network - -> generated gRPC server code from gen/ - -> ha-gateway gRPC adapter - -> ha-gateway app layer - -> ha-gateway Home Assistant REST adapter - -> Home Assistant -``` - -And then the response goes back in reverse. - -## Concrete Example: `/light on` - -Here is the real request path in this repo when a user runs `/light on` in -Discord. - -### 1. Discord receives the slash command - -`discord-bot` opens a Discord session in -[main.go](https://gitea.nik4nao.com/nik/home-services/src/branch/main/discord-bot/cmd/bot/main.go) -and registers a handler from -[handler.go](https://gitea.nik4nao.com/nik/home-services/src/branch/main/discord-bot/internal/adapters/primary/discord/handler.go). - -When the user runs: - -```text -/light on light:light.desk brightness:80 color_temp:3000 -``` - -Discord sends an interaction event to the bot. - -### 2. The Discord adapter routes the command - -In -[handler.go](https://gitea.nik4nao.com/nik/home-services/src/branch/main/discord-bot/internal/adapters/primary/discord/handler.go), -the command is routed by command name and subcommand: - -- `light.on` goes to `HandleLightOn(...)` - -This is still Discord-specific code. It knows about slash-command options and -ephemeral responses. - -### 3. The app layer turns the command into an internal use case - -In -[command.go](https://gitea.nik4nao.com/nik/home-services/src/branch/main/discord-bot/internal/app/command.go), -`HandleLightOn(...)` does two things: - -- looks up a friendly light name for the confirmation message -- calls the driven port `TurnOnLight(...)` - -This layer does not know protobuf details. It only knows the app wants to turn -on a light. - -### 4. The secondary adapter turns that use case into a gRPC request - -In -[client.go](https://gitea.nik4nao.com/nik/home-services/src/branch/main/discord-bot/internal/adapters/secondary/gateway/client.go), -`TurnOnLight(...)` creates: - -- a generated protobuf request: `hav1.TurnOnRequest` -- and sends it with a generated gRPC client: `hav1.LightServiceClient` - -This is the exact place where `discord-bot` depends on `gen/`. - -Why? - -Because `hav1.TurnOnRequest` and `hav1.LightServiceClient` are generated from -`proto/ha/v1/light.proto`. - -### 5. gRPC sends the protobuf message over the network - -The generated client serializes the request into protobuf bytes and sends it to -`ha-gateway` over gRPC. - -At this point: - -- `discord-bot` does not know anything about Home Assistant REST -- it only knows the gRPC contract defined in `proto/` - -### 6. `ha-gateway` receives the RPC - -In -[main.go](https://gitea.nik4nao.com/nik/home-services/src/branch/main/ha-gateway/cmd/gateway/main.go), -`ha-gateway` registers the generated server bindings: - -- `hav1.RegisterLightServiceServer(...)` -- `hav1.RegisterSwitchServiceServer(...)` -- `hav1.RegisterEntityServiceServer(...)` - -The generated gRPC server code from `gen/` receives the network request and -dispatches it to the real handler implementation in -[light.go](https://gitea.nik4nao.com/nik/home-services/src/branch/main/ha-gateway/internal/adapters/primary/grpc/light.go). - -This is the exact mirror image of the client side: - -- `discord-bot` uses generated client code -- `ha-gateway` uses generated server code - -### 7. The gRPC adapter maps protobuf types into domain input - -In -[light.go](https://gitea.nik4nao.com/nik/home-services/src/branch/main/ha-gateway/internal/adapters/primary/grpc/light.go), -the handler receives `*hav1.TurnOnRequest`. - -It converts that protobuf message into domain parameters and calls the app -service. - -This separation matters because it keeps the core app logic from depending on -protobuf or transport-specific concerns. - -### 8. The app layer decides what Home Assistant call to make - -In `ha-gateway/internal/app/light.go`, the app layer builds a service payload -such as: - -```json -{ - "entity_id": "light.desk", - "brightness_pct": 80, - "color_temp_kelvin": 3000 -} -``` - -Then it calls the driven Home Assistant client. - -### 9. The Home Assistant adapter performs the REST call - -In -[client.go](https://gitea.nik4nao.com/nik/home-services/src/branch/main/ha-gateway/internal/adapters/secondary/ha/client.go), -`CallService(...)` sends an HTTP request to Home Assistant: - -- `POST /api/services/light/turn_on` - -using the Home Assistant base URL and token from env config. - -### 10. The response comes back up the stack - -Then the flow reverses: - -1. Home Assistant returns JSON -2. `ha-gateway` maps it into internal state -3. `ha-gateway` maps internal state into generated protobuf response types -4. generated gRPC server code serializes the response -5. generated gRPC client code in `discord-bot` deserializes it -6. `discord-bot` app layer formats a human response like: - `Turned on 'Desk Lamp' (brightness 80%, 3000K).` -7. the Discord adapter sends that message back to Discord - -## Why This Is Split Into Multiple Layers - -If you are new to gRPC architecture, it can feel like there are too many steps. -Each layer has a specific job: - -- `proto/`: defines the API contract -- `gen/`: generated transport code from the contract -- `discord-bot` primary adapter: understands Discord interactions -- `discord-bot` app layer: understands command behavior -- `discord-bot` secondary adapter: understands how to call `ha-gateway` -- `ha-gateway` primary adapter: understands incoming gRPC requests -- `ha-gateway` app layer: understands home-control use cases -- `ha-gateway` secondary adapter: understands Home Assistant REST - -This gives you clean boundaries: - -- change Discord UX without changing Home Assistant integration -- change Home Assistant REST details without changing Discord commands -- add another client later, like CLI or Alexa, using the same gRPC contract - -## `proto/` vs `gen/` in Simple Terms - -Think of it like this: - -- `proto/` is the design -- `gen/` is the compiled implementation of that design for Go - -Example: - -- `proto/ha/v1/light.proto` says there is a `LightService` with `TurnOn` -- `gen/ha/v1/light_grpc.pb.go` generates the Go client/server plumbing -- `gen/ha/v1/light.pb.go` generates the Go message structs - -Both services use the same generated package: - -- same method names -- same fields -- same wire format - -That is what keeps both sides compatible. - -## Current Service Status - -### Implemented in `ha-gateway` - -- `ha.v1.EntityService` - - `GetState` - - `ListStates` -- `ha.v1.LightService` - - `ListLights` - - `TurnOn` - - `TurnOff` - - `Toggle` -- `ha.v1.SwitchService` - - `ListSwitches` - -### Partially Stubbed in `ha-gateway` - -- `ha.v1.SwitchService` - - `TurnOn` - - `TurnOff` - - `Toggle` - -### Stubbed in `ha-gateway` - -- `ha.v1.EventService` - -## Workspace Setup - -This repo uses a Go workspace: - -```text -go.work - -> ./gen - -> ./ha-gateway - -> ./discord-bot -``` - -That lets local modules import each other without publishing intermediate -versions while developing. - -## Configuration - -### `ha-gateway` - -Sample env file: -[ha-gateway/.env.example](https://gitea.nik4nao.com/nik/home-services/src/branch/main/ha-gateway/.env.example) - -| Variable | Required | Default | Notes | -| --- | --- | --- | --- | -| `HA_TOKEN` | yes | none | Home Assistant long-lived access token | -| `GRPC_PORT` | no | `50051` | gRPC listen port | -| `HA_BASE_URL` | effectively yes | empty | Example: `http://ha.home.arpa:8123` | -| `OTEL_ENDPOINT` | no | empty | OTLP gRPC endpoint | - -### `discord-bot` - -Sample env file: -[discord-bot/.env.example](https://gitea.nik4nao.com/nik/home-services/src/branch/main/discord-bot/.env.example) - -| Variable | Required | Notes | +| Variable | Default | Description | | --- | --- | --- | -| `DISCORD_TOKEN` | yes | Discord bot token | -| `GUILD_ID` | no | Guild command registration target | -| `HA_GATEWAY_ADDR` | yes | gRPC address of `ha-gateway` | -| `OTEL_ENDPOINT` | no | OTLP gRPC endpoint | +| `GRPC_PORT` | `50051` | gRPC listen port | +| `HA_BASE_URL` | empty | Base URL for Home Assistant | +| `HA_TOKEN` | none | Home Assistant long-lived access token | +| `OTEL_ENDPOINT` | empty | OTLP gRPC collector endpoint; empty disables telemetry | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` | +| `LOG_FORMAT` | `json` | `json` or `text` | -## Generate Protobuf Code +Auth: -Run from the repo root: +Inbound auth is not implemented yet. The current recommendation is mTLS between +internal services. A per-client API key interceptor is a possible alternative +for simpler environments. + +### discord-bot + +`discord-bot` provides Discord slash commands for home control and discovery. + +- Connects to `ha-gateway` over internal gRPC +- Supports slash-command control for lights +- Supports list/discovery responses for lights and switches +- Observability: + - structured logging via `slog` + - OpenTelemetry traces and metrics via OTLP gRPC + +Config: + +| Variable | Default | Description | +| --- | --- | --- | +| `DISCORD_TOKEN` | none | Discord bot token | +| `GUILD_ID` | empty | Guild-scoped command registration target | +| `HA_GATEWAY_ADDR` | none | gRPC address of `ha-gateway` | +| `OTEL_ENDPOINT` | empty | OTLP gRPC collector endpoint; empty disables telemetry | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` | +| `LOG_FORMAT` | `json` | `json` or `text` | + +Auth: + +No additional app-layer auth is implemented yet. The bot currently relies on +Discord authentication and the internal network boundary. + +### alexa-bridge + +`alexa-bridge` is a planned public HTTPS webhook service that will: + +- validate Alexa request signatures +- translate Alexa directives into gRPC calls to `ha-gateway` +- keep `ha-gateway` off the public internet + +Status: + +- stubbed concept only +- not implemented in this repo yet + +## Repo Structure + +```text +home-services/ +├── proto/ # Source of truth for protobuf service contracts +│ └── ha/v1/ +├── gen/ # Committed generated Go protobuf/gRPC code; do not edit +├── ha-gateway/ +│ ├── cmd/gateway/ # Process entrypoint and startup wiring +│ └── internal/ +│ ├── core/ # Pure domain and port definitions, no framework logic +│ ├── app/ # Orchestration between ports +│ ├── adapters/ # gRPC handlers, HA client, and other I/O +│ ├── logger/ # Context-aware slog helpers +│ └── telemetry/ # OpenTelemetry setup and shutdown +├── discord-bot/ +│ ├── cmd/bot/ # Process entrypoint and Discord session wiring +│ └── internal/ +│ ├── app/ # Command orchestration and formatting +│ ├── adapters/ # Discord interaction handlers and gRPC client +│ ├── core/ports/driven/ # App-facing ha-gateway contract +│ ├── logger/ # Context-aware slog helpers +│ └── telemetry/ # OpenTelemetry setup and shutdown +├── buf.yaml # Buf module config +├── buf.gen.yaml # Buf generation config +└── go.work # Go workspace for local module development +``` + +## Local Development + +Prerequisites: + +- Go `1.26+` +- `buf` +- `grpcurl` +- a running Home Assistant instance + +Setup: ```bash +# Clone and set up workspace +git clone https://gitea.nik4nao.com/nik/home-services +cd home-services +go work sync + +# Regenerate proto (only needed if .proto files change) buf generate -``` -That regenerates `gen/ha/v1/*.pb.go` from `proto/ha/v1/*.proto`. - -## Build - -Build each module from its own directory: - -```bash -cd gen && go build ./... -cd ../ha-gateway && go build ./... -cd ../discord-bot && go build ./... -``` - -## Run Locally - -### Start `ha-gateway` - -```bash +# Configure ha-gateway cp ha-gateway/.env.example ha-gateway/.env -cd ha-gateway -go run ./cmd/gateway +# Edit ha-gateway/.env — set HA_BASE_URL and HA_TOKEN + +# Run ha-gateway +cd ha-gateway && go run ./cmd/gateway + +# Run discord-bot (separate terminal) +cd discord-bot && go run ./cmd/bot ``` -### Start `discord-bot` +Smoke testing with `grpcurl`: ```bash -cp discord-bot/.env.example discord-bot/.env -cd discord-bot -go run ./cmd/bot -``` - -## Smoke Test `ha-gateway` - -Because `ha-gateway` registers gRPC reflection, you can inspect it with -`grpcurl`: - -```bash -grpcurl -plaintext localhost:50051 list -grpcurl -plaintext localhost:50051 describe ha.v1.LightService -``` - -Examples: - -```bash -grpcurl -plaintext -d '{}' \ - localhost:50051 ha.v1.LightService/ListLights +# List all lights +grpcurl -plaintext -d '{"domain":"light"}' \ + localhost:50051 ha.v1.EntityService/ListStates +# Turn on a light grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' \ localhost:50051 ha.v1.LightService/TurnOn - -grpcurl -plaintext -d '{}' \ - localhost:50051 ha.v1.SwitchService/ListSwitches ``` -## Docker - -Build from the repo root: +## Building ```bash +# Build ha-gateway image (run from repo root) docker build -f ha-gateway/Dockerfile -t ha-gateway:dev . + +# Build discord-bot image docker build -f discord-bot/Dockerfile -t discord-bot:dev . ``` -## Telemetry +## What's Next -Both runtime services support OTLP/gRPC telemetry: - -- `ha-gateway` uses service name `ha-gateway` -- `discord-bot` uses service name `discord-bot` - -If `OTEL_ENDPOINT` is empty, telemetry is disabled. - -## Current Limitations - -- no auth on inbound gRPC requests yet -- `SwitchService` control RPCs are still unimplemented -- `EventService` is still unimplemented -- Home Assistant event streaming over WebSocket is not implemented -- there are currently no unit tests in the repo +- Auth: mTLS between services using an internal cert-manager CA +- SwitchService control implementation +- EventService with Home Assistant WebSocket fan-out broker +- `alexa-bridge` implementation +- Gitea CI pipeline for automated build and push diff --git a/discord-bot/cmd/bot/main.go b/discord-bot/cmd/bot/main.go index 30fbdc2..56a48fd 100644 --- a/discord-bot/cmd/bot/main.go +++ b/discord-bot/cmd/bot/main.go @@ -23,6 +23,7 @@ var version = "dev" func main() { _ = godotenv.Load() + // Config is loaded before logger setup so fatal startup errors still stop early. cfg, err := config.Load() if err != nil { os.Stderr.WriteString("config error: " + err.Error() + "\n") @@ -42,6 +43,7 @@ func main() { defer stop() ctx = logger.WithLogger(ctx, log) + // Telemetry is optional; an empty OTEL endpoint installs no-op providers. shutdown, err := telemetry.Setup(ctx, "discord-bot", version, cfg) if err != nil { log.Error("telemetry setup failed", "err", err) @@ -66,6 +68,7 @@ func main() { commandApp := app.NewCommandApp(haClient) + // Discord-specific wiring stays at the edge so the app layer remains transport-agnostic. session, err := discordgo.New("Bot " + cfg.DiscordToken) if err != nil { log.Error("create discord session failed", "err", err) @@ -108,6 +111,8 @@ func main() { } } +// redactToken logs only a short prefix so startup logs remain useful without +// leaking the full Discord bot token. func redactToken(token string) string { if token == "" { return "[not set]" diff --git a/discord-bot/internal/adapters/primary/discord/handler.go b/discord-bot/internal/adapters/primary/discord/handler.go index d11d999..8596043 100644 --- a/discord-bot/internal/adapters/primary/discord/handler.go +++ b/discord-bot/internal/adapters/primary/discord/handler.go @@ -27,18 +27,23 @@ type commandHandler interface { AutocompleteSwitches(ctx context.Context) ([]apppkg.Choice, error) } +// Handler adapts Discord interactions to the command application layer. type Handler struct { app commandHandler } +// NewHandler constructs the Discord interaction adapter. func NewHandler(app commandHandler) *Handler { return &Handler{app: app} } +// Register attaches the interaction handler to the Discord session. func (h *Handler) Register(s *discordgo.Session) { s.AddHandler(h.onInteractionCreate) } +// onInteractionCreate creates a per-interaction context so downstream logs and +// spans share the same command, user, and guild metadata. func (h *Handler) onInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -52,6 +57,8 @@ func (h *Handler) onInteractionCreate(s *discordgo.Session, i *discordgo.Interac } } +// handleApplicationCommand routes slash commands to the app layer and ensures +// Discord still gets a response when command execution fails. func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { log := logger.FromContext(ctx) start := time.Now() @@ -134,6 +141,8 @@ func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Ses } } +// handleAutocomplete keeps autocomplete fast by filtering already-fetched choices +// instead of requiring Discord-specific logic in the app layer. func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { log := logger.FromContext(ctx) data := i.ApplicationCommandData() @@ -180,6 +189,7 @@ func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session, } } +// deferResponse acknowledges long-running actions so Discord does not time them out. func (h *Handler) deferResponse(s *discordgo.Session, interaction *discordgo.Interaction, ephemeral bool) error { data := &discordgo.InteractionResponseData{} if ephemeral { @@ -194,6 +204,7 @@ func (h *Handler) deferResponse(s *discordgo.Session, interaction *discordgo.Int return nil } +// respondMessage centralizes one-shot Discord responses and consistent logging. func (h *Handler) respondMessage(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, content string, ephemeral bool) { log := logger.FromContext(ctx) data := &discordgo.InteractionResponseData{Content: content} @@ -208,6 +219,7 @@ func (h *Handler) respondMessage(ctx context.Context, s *discordgo.Session, inte } } +// respondError keeps user-visible errors simple while preserving structured logs. func (h *Handler) respondError(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, ephemeral bool, start time.Time, err error) { logger.FromContext(ctx).Error("command failed", "duration_ms", time.Since(start).Milliseconds(), @@ -216,6 +228,7 @@ func (h *Handler) respondError(ctx context.Context, s *discordgo.Session, intera h.respondMessage(ctx, s, interaction, fmt.Sprintf("Error: %v", err), ephemeral) } +// followup sends the final payload for deferred action commands. func (h *Handler) followup(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, content string, ephemeral bool, start time.Time, err error) { log := logger.FromContext(ctx) if err != nil { @@ -236,6 +249,7 @@ func (h *Handler) followup(ctx context.Context, s *discordgo.Session, interactio } } +// interactionLogger adds stable interaction metadata without logging noisy option values. func interactionLogger(ctx context.Context, i *discordgo.InteractionCreate) *slog.Logger { log := logger.FromContext(ctx) data := i.ApplicationCommandData() diff --git a/discord-bot/internal/adapters/primary/discord/register.go b/discord-bot/internal/adapters/primary/discord/register.go index 32bc6ae..4cbab8b 100644 --- a/discord-bot/internal/adapters/primary/discord/register.go +++ b/discord-bot/internal/adapters/primary/discord/register.go @@ -6,6 +6,7 @@ import ( "github.com/bwmarrin/discordgo" ) +// RegisterCommands upserts the bot's slash command definitions. func RegisterCommands(s *discordgo.Session, guildID string) error { appID := s.State.User.ID if appID == "" { @@ -88,6 +89,7 @@ func RegisterCommands(s *discordgo.Session, guildID string) error { return nil } +// lightOption centralizes the shared light entity selector used by subcommands. func lightOption() *discordgo.ApplicationCommandOption { return &discordgo.ApplicationCommandOption{ Type: discordgo.ApplicationCommandOptionString, @@ -98,6 +100,7 @@ func lightOption() *discordgo.ApplicationCommandOption { } } +// ptrFloat keeps command option min/max values readable in the command spec. func ptrFloat(v float64) *float64 { return &v } diff --git a/discord-bot/internal/adapters/secondary/gateway/client.go b/discord-bot/internal/adapters/secondary/gateway/client.go index 6681125..5bba96e 100644 --- a/discord-bot/internal/adapters/secondary/gateway/client.go +++ b/discord-bot/internal/adapters/secondary/gateway/client.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc/credentials/insecure" ) +// Client implements the app's HA driven port over gRPC. type Client struct { conn *grpc.ClientConn lightClient hav1.LightServiceClient @@ -21,6 +22,7 @@ type Client struct { log *slog.Logger } +// New constructs a gRPC client for the internal ha-gateway service. func New(ctx context.Context, addr string, log *slog.Logger) (*Client, error) { conn, err := grpc.NewClient( addr, @@ -39,6 +41,7 @@ func New(ctx context.Context, addr string, log *slog.Logger) (*Client, error) { }, nil } +// Close closes the underlying gRPC connection. func (c *Client) Close() error { if err := c.conn.Close(); err != nil { return fmt.Errorf("close ha-gateway client: %w", err) @@ -46,6 +49,8 @@ func (c *Client) Close() error { return nil } +// ListLights calls ha-gateway discovery RPCs and maps protobuf messages into +// the driven port type expected by the app layer. func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) { start := time.Now() log := logger.FromContext(ctx).With("grpc.method", "LightService/ListLights") @@ -75,6 +80,8 @@ func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) { return lights, nil } +// ListSwitches calls ha-gateway discovery RPCs and maps protobuf messages into +// the driven port type expected by the app layer. func (c *Client) ListSwitches(ctx context.Context) ([]driven.Switch, error) { start := time.Now() log := logger.FromContext(ctx).With("grpc.method", "SwitchService/ListSwitches") @@ -100,6 +107,7 @@ func (c *Client) ListSwitches(ctx context.Context) ([]driven.Switch, error) { return switches, nil } +// TurnOnLight forwards a light turn-on request over gRPC. func (c *Client) TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error { start := time.Now() log := logger.FromContext(ctx).With("grpc.method", "LightService/TurnOn") @@ -122,6 +130,7 @@ func (c *Client) TurnOnLight(ctx context.Context, entityID string, brightnessPct return nil } +// TurnOffLight forwards a light turn-off request over gRPC. func (c *Client) TurnOffLight(ctx context.Context, entityID string, transition *uint32) error { start := time.Now() log := logger.FromContext(ctx).With("grpc.method", "LightService/TurnOff") @@ -141,6 +150,7 @@ func (c *Client) TurnOffLight(ctx context.Context, entityID string, transition * return nil } +// ToggleLight forwards a light toggle request over gRPC. func (c *Client) ToggleLight(ctx context.Context, entityID string) error { start := time.Now() log := logger.FromContext(ctx).With("grpc.method", "LightService/Toggle") diff --git a/discord-bot/internal/app/command.go b/discord-bot/internal/app/command.go index 3ac6b0c..c7a488c 100644 --- a/discord-bot/internal/app/command.go +++ b/discord-bot/internal/app/command.go @@ -9,19 +9,23 @@ import ( "gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven" ) +// Choice is one Discord autocomplete entry. type Choice struct { Label string Value string } +// CommandApp orchestrates Discord command use cases against ha-gateway. type CommandApp struct { ha driven.HAGateway } +// NewCommandApp constructs the Discord command application service. func NewCommandApp(ha driven.HAGateway) *CommandApp { return &CommandApp{ha: ha} } +// HandleLightList formats discovered lights into a monospace-friendly response. func (a *CommandApp) HandleLightList(ctx context.Context) (string, error) { lights, err := a.ha.ListLights(ctx) if err != nil { @@ -40,6 +44,7 @@ func (a *CommandApp) HandleLightList(ctx context.Context) (string, error) { return strings.Join(lines, "\n"), nil } +// HandleLightOn issues a turn-on request and returns a user-facing confirmation. func (a *CommandApp) HandleLightOn(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) (string, error) { name, err := a.lookupLightName(ctx, entityID) if err != nil { @@ -62,6 +67,7 @@ func (a *CommandApp) HandleLightOn(ctx context.Context, entityID string, brightn return fmt.Sprintf("Turned on `%s` (%s).", name, strings.Join(details, ", ")), nil } +// HandleLightOff issues a turn-off request and returns a user-facing confirmation. func (a *CommandApp) HandleLightOff(ctx context.Context, entityID string, transition *uint32) (string, error) { name, err := a.lookupLightName(ctx, entityID) if err != nil { @@ -76,6 +82,7 @@ func (a *CommandApp) HandleLightOff(ctx context.Context, entityID string, transi return fmt.Sprintf("Turned off `%s` with %ds transition.", name, *transition), nil } +// HandleLightToggle issues a toggle request and returns a user-facing confirmation. func (a *CommandApp) HandleLightToggle(ctx context.Context, entityID string) (string, error) { name, err := a.lookupLightName(ctx, entityID) if err != nil { @@ -87,6 +94,7 @@ func (a *CommandApp) HandleLightToggle(ctx context.Context, entityID string) (st return fmt.Sprintf("Toggled `%s`.", name), nil } +// HandleSwitchList formats discovered switches into a monospace-friendly response. func (a *CommandApp) HandleSwitchList(ctx context.Context) (string, error) { switches, err := a.ha.ListSwitches(ctx) if err != nil { @@ -113,6 +121,7 @@ func (a *CommandApp) HandleSwitchList(ctx context.Context) (string, error) { return strings.Join(lines, "\n"), nil } +// AutocompleteLights maps discovered lights into Discord autocomplete choices. func (a *CommandApp) AutocompleteLights(ctx context.Context) ([]Choice, error) { lights, err := a.ha.ListLights(ctx) if err != nil { @@ -130,6 +139,7 @@ func (a *CommandApp) AutocompleteLights(ctx context.Context) ([]Choice, error) { return choices, nil } +// AutocompleteSwitches maps discovered switches into Discord autocomplete choices. func (a *CommandApp) AutocompleteSwitches(ctx context.Context) ([]Choice, error) { switches, err := a.ha.ListSwitches(ctx) if err != nil { @@ -147,6 +157,8 @@ func (a *CommandApp) AutocompleteSwitches(ctx context.Context) ([]Choice, error) return choices, nil } +// lookupLightName falls back to the entity ID so confirmations remain useful +// even when Home Assistant does not expose a friendly name. func (a *CommandApp) lookupLightName(ctx context.Context, entityID string) (string, error) { lights, err := a.ha.ListLights(ctx) if err != nil { @@ -164,6 +176,8 @@ func (a *CommandApp) lookupLightName(ctx context.Context, entityID string) (stri return lights[idx].FriendlyName, nil } +// formatLightLine keeps list output compact because Discord code blocks are +// easier to scan than rich embeds for dense discovery data. func formatLightLine(light driven.Light) string { label := light.FriendlyName if label == "" { @@ -188,6 +202,7 @@ func formatLightLine(light driven.Light) string { return fmt.Sprintf("%s %-15s %-12s %s", stateEmoji(light.State), label, light.State, details) } +// stateEmoji compresses common Home Assistant states into visually scannable output. func stateEmoji(state string) string { switch state { case "on": diff --git a/discord-bot/internal/config/config.go b/discord-bot/internal/config/config.go index 3acfe37..cdd50fe 100644 --- a/discord-bot/internal/config/config.go +++ b/discord-bot/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" ) +// Config holds runtime configuration for the Discord bot process. type Config struct { DiscordToken string GuildID string @@ -14,6 +15,7 @@ type Config struct { LogFormat string } +// Load reads Discord bot configuration from environment variables. func Load() (*Config, error) { token := os.Getenv("DISCORD_TOKEN") if token == "" { @@ -35,6 +37,7 @@ func Load() (*Config, error) { }, nil } +// getenvDefault keeps config loading concise for optional variables with defaults. func getenvDefault(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/discord-bot/internal/core/ports/driven/ha.go b/discord-bot/internal/core/ports/driven/ha.go index 6255237..048fee1 100644 --- a/discord-bot/internal/core/ports/driven/ha.go +++ b/discord-bot/internal/core/ports/driven/ha.go @@ -3,27 +3,46 @@ package driven import "context" type HAGateway interface { + // ListLights returns light discovery data from ha-gateway. ListLights(ctx context.Context) ([]Light, error) + // ListSwitches returns switch discovery data from ha-gateway. ListSwitches(ctx context.Context) ([]Switch, error) + // TurnOnLight forwards a light turn-on request to ha-gateway. TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error + // TurnOffLight forwards a light turn-off request to ha-gateway. TurnOffLight(ctx context.Context, entityID string, transition *uint32) error + // ToggleLight forwards a light toggle request to ha-gateway. ToggleLight(ctx context.Context, entityID string) error } +// Light is the discovery-oriented light view exposed by ha-gateway. type Light struct { - EntityID string - FriendlyName string - State string + // EntityID is the Home Assistant entity identifier. + EntityID string + // FriendlyName is the user-facing name from Home Assistant. + FriendlyName string + // State is the raw light state string. + State string + // SupportedColorModes mirrors Home Assistant supported_color_modes. SupportedColorModes []string - MinColorTempKelvin uint32 - MaxColorTempKelvin uint32 - IsHueGroup bool - EffectList []string + // MinColorTempKelvin is the lower supported color temperature bound. + MinColorTempKelvin uint32 + // MaxColorTempKelvin is the upper supported color temperature bound. + MaxColorTempKelvin uint32 + // IsHueGroup marks synthetic Hue group entities. + IsHueGroup bool + // EffectList contains supported named effects when available. + EffectList []string } +// Switch is the discovery-oriented switch view exposed by ha-gateway. type Switch struct { - EntityID string + // EntityID is the Home Assistant entity identifier. + EntityID string + // FriendlyName is the user-facing name from Home Assistant. FriendlyName string - State string - DeviceClass string + // State is the raw switch state string. + State string + // DeviceClass describes the semantic type when Home Assistant provides it. + DeviceClass string } diff --git a/discord-bot/internal/logger/logger.go b/discord-bot/internal/logger/logger.go index 2232a97..498bf86 100644 --- a/discord-bot/internal/logger/logger.go +++ b/discord-bot/internal/logger/logger.go @@ -9,6 +9,7 @@ import ( type contextKey struct{} +// New constructs a root logger for the configured format and level. func New(format, level string) *slog.Logger { var parsed slog.Level if err := parsed.UnmarshalText([]byte(level)); err != nil { @@ -23,10 +24,12 @@ func New(format, level string) *slog.Logger { return slog.New(slog.NewTextHandler(os.Stdout, opts)) } +// WithLogger attaches a logger to the provided context. func WithLogger(ctx context.Context, l *slog.Logger) context.Context { return context.WithValue(ctx, contextKey{}, l) } +// FromContext retrieves a logger from context and falls back to slog.Default(). func FromContext(ctx context.Context) *slog.Logger { if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil { return l diff --git a/discord-bot/internal/telemetry/telemetry.go b/discord-bot/internal/telemetry/telemetry.go index 232d02b..16b244f 100644 --- a/discord-bot/internal/telemetry/telemetry.go +++ b/discord-bot/internal/telemetry/telemetry.go @@ -20,6 +20,7 @@ import ( "gitea.nik4nao.com/nik/home-services/discord-bot/internal/config" ) +// Setup initialises OTel trace and metric providers for one service. func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) (shutdown func(context.Context) error, err error) { if cfg.OTELEndpoint == "" { otel.SetTracerProvider(tracenoop.NewTracerProvider()) @@ -68,6 +69,7 @@ func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) otel.SetMeterProvider(mp) return func(ctx context.Context) error { + // Shutdown is bounded so exporter flushes cannot stall process exit forever. shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() var shutdownErr error diff --git a/ha-gateway/cmd/gateway/main.go b/ha-gateway/cmd/gateway/main.go index b696e54..b7a23a4 100644 --- a/ha-gateway/cmd/gateway/main.go +++ b/ha-gateway/cmd/gateway/main.go @@ -38,6 +38,7 @@ var version = "dev" func main() { _ = godotenv.Load() + // Config is loaded before logger setup so fatal startup errors still stop early. cfg, err := config.Load() if err != nil { os.Stderr.WriteString("config error: " + err.Error() + "\n") @@ -59,6 +60,7 @@ func main() { defer stop() ctx = logger.WithLogger(ctx, log) + // Telemetry is optional; an empty OTEL endpoint installs no-op providers. shutdown, err := telemetry.Setup(ctx, "ha-gateway", version, cfg) if err != nil { log.Error("telemetry setup failed", "err", err) @@ -72,6 +74,7 @@ func main() { haClient := ha.NewClient(cfg, log) + // App services stay free of gRPC and HTTP details; adapters are wired here. entityApp := app.NewEntityApp(haClient) lightApp := app.NewLightApp(haClient) switchApp := app.NewSwitchApp(haClient) @@ -123,6 +126,8 @@ func main() { } } +// redactToken logs only a short prefix so startup logs remain useful without +// leaking a full Home Assistant credential. func redactToken(token string) string { if token == "" { return "[not set]" diff --git a/ha-gateway/internal/adapters/primary/grpc/entity.go b/ha-gateway/internal/adapters/primary/grpc/entity.go index 1ee61c2..7ccfc84 100644 --- a/ha-gateway/internal/adapters/primary/grpc/entity.go +++ b/ha-gateway/internal/adapters/primary/grpc/entity.go @@ -17,10 +17,12 @@ type EntityGRPC struct { svc driving.EntityService } +// NewEntityGRPC constructs the gRPC adapter for EntityService. func NewEntityGRPC(svc driving.EntityService) *EntityGRPC { return &EntityGRPC{svc: svc} } +// GetState translates a protobuf request into a domain call and back again. func (h *EntityGRPC) GetState(ctx context.Context, req *hav1.GetStateRequest) (*hav1.GetStateResponse, error) { s, err := h.svc.GetState(ctx, domain.EntityID(req.EntityId)) if err != nil { @@ -29,6 +31,7 @@ func (h *EntityGRPC) GetState(ctx context.Context, req *hav1.GetStateRequest) (* return &hav1.GetStateResponse{State: domainStateToProto(s)}, nil } +// ListStates translates repeated protobuf IDs and filters into the domain call. func (h *EntityGRPC) ListStates(ctx context.Context, req *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) { ids := make([]domain.EntityID, len(req.EntityIds)) for i, id := range req.EntityIds { diff --git a/ha-gateway/internal/adapters/primary/grpc/event.go b/ha-gateway/internal/adapters/primary/grpc/event.go index 26bcac4..bcb1806 100644 --- a/ha-gateway/internal/adapters/primary/grpc/event.go +++ b/ha-gateway/internal/adapters/primary/grpc/event.go @@ -4,9 +4,9 @@ import ( hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" ) +// EventGRPC is the placeholder gRPC adapter for future event streaming. type EventGRPC struct { hav1.UnimplementedEventServiceServer } -// Subscribe returns codes.Unimplemented via the embedded UnimplementedEventServiceServer. -// TODO: inject fanout broker here once websocket.go is implemented. +// TODO: implement event fan-out broker wiring — see plan.md for context diff --git a/ha-gateway/internal/adapters/primary/grpc/interceptor.go b/ha-gateway/internal/adapters/primary/grpc/interceptor.go index d6e2bb1..0887233 100644 --- a/ha-gateway/internal/adapters/primary/grpc/interceptor.go +++ b/ha-gateway/internal/adapters/primary/grpc/interceptor.go @@ -12,6 +12,7 @@ import ( "google.golang.org/grpc/status" ) +// LoggingUnaryInterceptor logs one completion record for each unary gRPC call. func LoggingUnaryInterceptor(log *slog.Logger) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { method, ok := grpc.Method(ctx) @@ -28,6 +29,7 @@ func LoggingUnaryInterceptor(log *slog.Logger) grpc.UnaryServerInterceptor { } } +// LoggingStreamInterceptor logs stream start and completion with request-scoped fields. func LoggingStreamInterceptor(log *slog.Logger) grpc.StreamServerInterceptor { return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { reqLog := requestLogger(ss.Context(), log, info.FullMethod) @@ -49,10 +51,13 @@ type loggingServerStream struct { ctx context.Context } +// Context returns the request-scoped context with the derived logger attached. func (s *loggingServerStream) Context() context.Context { return s.ctx } +// requestLogger derives a child logger so every downstream component sees the +// same gRPC method and peer metadata through context propagation. func requestLogger(ctx context.Context, log *slog.Logger, method string) *slog.Logger { peerAddr := "" if p, ok := peer.FromContext(ctx); ok && p.Addr != nil { @@ -61,6 +66,8 @@ func requestLogger(ctx context.Context, log *slog.Logger, method string) *slog.L return log.With("grpc.method", method, "grpc.peer", peerAddr) } +// logCompletion keeps severity consistent with gRPC status semantics so +// expected client-facing errors do not look like infrastructure failures. func logCompletion(log *slog.Logger, msg string, code codes.Code, duration time.Duration, err error) { attrs := []any{ "duration_ms", duration.Milliseconds(), diff --git a/ha-gateway/internal/adapters/primary/grpc/light.go b/ha-gateway/internal/adapters/primary/grpc/light.go index 1c7e0b4..b9da5a8 100644 --- a/ha-gateway/internal/adapters/primary/grpc/light.go +++ b/ha-gateway/internal/adapters/primary/grpc/light.go @@ -13,10 +13,12 @@ type LightGRPC struct { svc driving.LightService } +// NewLightGRPC constructs the gRPC adapter for LightService. func NewLightGRPC(svc driving.LightService) *LightGRPC { return &LightGRPC{svc: svc} } +// TurnOn maps a protobuf turn-on request into domain parameters. func (h *LightGRPC) TurnOn(ctx context.Context, req *hav1.TurnOnRequest) (*hav1.LightResponse, error) { s, err := h.svc.TurnOn(ctx, protoTurnOnToParams(req)) if err != nil { @@ -25,6 +27,7 @@ func (h *LightGRPC) TurnOn(ctx context.Context, req *hav1.TurnOnRequest) (*hav1. return &hav1.LightResponse{State: domainStateToProto(s)}, nil } +// TurnOff maps a protobuf turn-off request into domain parameters. func (h *LightGRPC) TurnOff(ctx context.Context, req *hav1.TurnOffRequest) (*hav1.LightResponse, error) { s, err := h.svc.TurnOff(ctx, protoTurnOffToParams(req)) if err != nil { @@ -33,6 +36,7 @@ func (h *LightGRPC) TurnOff(ctx context.Context, req *hav1.TurnOffRequest) (*hav return &hav1.LightResponse{State: domainStateToProto(s)}, nil } +// Toggle forwards a light toggle request to the domain service. func (h *LightGRPC) Toggle(ctx context.Context, req *hav1.ToggleRequest) (*hav1.LightResponse, error) { s, err := h.svc.Toggle(ctx, domain.EntityID(req.EntityId)) if err != nil { @@ -41,6 +45,7 @@ func (h *LightGRPC) Toggle(ctx context.Context, req *hav1.ToggleRequest) (*hav1. return &hav1.LightResponse{State: domainStateToProto(s)}, nil } +// ListLights returns discovery-oriented light metadata for clients such as Discord. func (h *LightGRPC) ListLights(ctx context.Context, req *hav1.ListLightsRequest) (*hav1.ListLightsResponse, error) { lights, err := h.svc.ListLights(ctx) if err != nil { diff --git a/ha-gateway/internal/adapters/primary/grpc/mapping.go b/ha-gateway/internal/adapters/primary/grpc/mapping.go index b272040..55af6a9 100644 --- a/ha-gateway/internal/adapters/primary/grpc/mapping.go +++ b/ha-gateway/internal/adapters/primary/grpc/mapping.go @@ -5,6 +5,7 @@ import ( "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" ) +// domainStateToProto normalizes domain state timestamps and attributes for gRPC. func domainStateToProto(s *domain.EntityState) *hav1.EntityState { return &hav1.EntityState{ EntityId: string(s.EntityID), @@ -15,6 +16,8 @@ func domainStateToProto(s *domain.EntityState) *hav1.EntityState { } } +// protoTurnOnToParams preserves optional protobuf fields so the app layer can +// distinguish "unset" from explicit zero values. func protoTurnOnToParams(r *hav1.TurnOnRequest) domain.TurnOnParams { p := domain.TurnOnParams{ EntityID: domain.EntityID(r.EntityId), @@ -41,6 +44,7 @@ func protoTurnOnToParams(r *hav1.TurnOnRequest) domain.TurnOnParams { return p } +// protoTurnOffToParams preserves optional protobuf fields for turn-off calls. func protoTurnOffToParams(r *hav1.TurnOffRequest) domain.TurnOffParams { p := domain.TurnOffParams{ EntityID: domain.EntityID(r.EntityId), @@ -52,6 +56,7 @@ func protoTurnOffToParams(r *hav1.TurnOffRequest) domain.TurnOffParams { return p } +// domainLightToProto exposes discovery metadata without leaking domain enums. func domainLightToProto(l domain.Light) *hav1.LightEntity { modes := make([]string, len(l.SupportedColorModes)) for i, m := range l.SupportedColorModes { @@ -69,6 +74,7 @@ func domainLightToProto(l domain.Light) *hav1.LightEntity { } } +// domainSwitchToProto exposes switch discovery metadata over gRPC. func domainSwitchToProto(s domain.Switch) *hav1.SwitchEntity { return &hav1.SwitchEntity{ EntityId: string(s.EntityID), diff --git a/ha-gateway/internal/adapters/primary/grpc/switch.go b/ha-gateway/internal/adapters/primary/grpc/switch.go index 677c9b4..26b97d3 100644 --- a/ha-gateway/internal/adapters/primary/grpc/switch.go +++ b/ha-gateway/internal/adapters/primary/grpc/switch.go @@ -12,10 +12,12 @@ type SwitchGRPC struct { svc driving.SwitchService } +// NewSwitchGRPC constructs the gRPC adapter for SwitchService. func NewSwitchGRPC(svc driving.SwitchService) *SwitchGRPC { return &SwitchGRPC{svc: svc} } +// ListSwitches returns discovery-oriented switch metadata for clients. func (h *SwitchGRPC) ListSwitches(ctx context.Context, req *hav1.ListSwitchesRequest) (*hav1.ListSwitchesResponse, error) { switches, err := h.svc.ListSwitches(ctx) if err != nil { @@ -28,17 +30,20 @@ func (h *SwitchGRPC) ListSwitches(ctx context.Context, req *hav1.ListSwitchesReq return &hav1.ListSwitchesResponse{Switches: out}, nil } -// TurnOn, TurnOff, Toggle — left as Unimplemented for now. -// TODO: implement once app/switch.go has callService support. -// Follow the same pattern as LightGRPC: payload{"entity_id": ...} → ha.CallService. +// TurnOn is stubbed until switch control orchestration exists in the app layer. +// TODO: implement switch control RPCs — see plan.md for context func (h *SwitchGRPC) TurnOn(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { return nil, grpcError(ErrNotImplemented) } +// TurnOff is stubbed until switch control orchestration exists in the app layer. +// TODO: implement switch control RPCs — see plan.md for context func (h *SwitchGRPC) TurnOff(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { return nil, grpcError(ErrNotImplemented) } +// Toggle is stubbed until switch control orchestration exists in the app layer. +// TODO: implement switch control RPCs — see plan.md for context func (h *SwitchGRPC) Toggle(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { return nil, grpcError(ErrNotImplemented) } diff --git a/ha-gateway/internal/adapters/secondary/ha/client.go b/ha-gateway/internal/adapters/secondary/ha/client.go index c905c7c..d97ff02 100644 --- a/ha-gateway/internal/adapters/secondary/ha/client.go +++ b/ha-gateway/internal/adapters/secondary/ha/client.go @@ -21,6 +21,7 @@ import ( var tracer = otel.Tracer("ha-gateway/ha-client") +// Client implements the HA driven port over Home Assistant's REST API. type Client struct { baseURL string token string @@ -28,6 +29,7 @@ type Client struct { log *slog.Logger } +// NewClient constructs a REST client configured for one Home Assistant instance. func NewClient(cfg *config.Config, log *slog.Logger) *Client { return &Client{ baseURL: strings.TrimRight(cfg.HABaseURL, "/"), @@ -37,6 +39,7 @@ func NewClient(cfg *config.Config, log *slog.Logger) *Client { } } +// GetState fetches one entity state from the Home Assistant states endpoint. func (c *Client) GetState(ctx context.Context, entityID string) (*driven.HAState, error) { ctx, span := tracer.Start(ctx, "ha.GetState", trace.WithAttributes(attribute.String("ha.entity_id", entityID)), @@ -52,6 +55,7 @@ func (c *Client) GetState(ctx context.Context, entityID string) (*driven.HAState return raw.toDriven() } +// ListStates fetches the full Home Assistant state list for discovery use cases. func (c *Client) ListStates(ctx context.Context) ([]*driven.HAState, error) { ctx, span := tracer.Start(ctx, "ha.ListStates") defer span.End() @@ -74,6 +78,8 @@ func (c *Client) ListStates(ctx context.Context) ([]*driven.HAState, error) { return out, nil } +// CallService invokes one Home Assistant domain/service pair and maps the +// optional returned entity list back into the driven port shape. func (c *Client) CallService(ctx context.Context, domain, service string, payload map[string]any) ([]*driven.HAState, error) { ctx, span := tracer.Start(ctx, "ha.CallService", trace.WithAttributes( @@ -149,6 +155,7 @@ func (c *Client) CallService(ctx context.Context, domain, service string, payloa return out, nil } +// get centralizes GET request construction and consistent non-2xx handling. func (c *Client) get(ctx context.Context, path string, dst any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { @@ -201,6 +208,8 @@ type haStateRaw struct { LastUpdated string `json:"last_updated"` } +// toDriven tolerates missing/invalid timestamps because Home Assistant payloads +// are more useful to callers than failing the whole request on parse issues. func (r *haStateRaw) toDriven() (*driven.HAState, error) { lc, err := time.Parse(time.RFC3339, r.LastChanged) if err != nil { diff --git a/ha-gateway/internal/adapters/secondary/ha/websocket.go b/ha-gateway/internal/adapters/secondary/ha/websocket.go index ed73575..4a298ad 100644 --- a/ha-gateway/internal/adapters/secondary/ha/websocket.go +++ b/ha-gateway/internal/adapters/secondary/ha/websocket.go @@ -1,7 +1,7 @@ package ha -// TODO: implement HA WebSocket client. +// TODO: implement HA WebSocket client — see plan.md for context // Auth flow: receive auth_required → send {"type":"auth","access_token":"..."} → receive auth_ok. -// Subscribe: send {"id":1,"type":"subscribe_events","event_type":"state_changed"} -// Events: stream {"type":"event","event":{"event_type":"state_changed","data":{...}}} -// This adapter will publish to the internal fanout broker once EventService is implemented. +// Subscribe: send {"id":1,"type":"subscribe_events","event_type":"state_changed"}. +// Events: stream {"type":"event","event":{"event_type":"state_changed","data":{...}}}. +// This adapter should publish to an internal fanout broker once EventService is implemented. diff --git a/ha-gateway/internal/app/entity.go b/ha-gateway/internal/app/entity.go index 59f0f08..46b2bc7 100644 --- a/ha-gateway/internal/app/entity.go +++ b/ha-gateway/internal/app/entity.go @@ -13,10 +13,12 @@ type EntityApp struct { ha driven.HAClient } +// NewEntityApp constructs the entity application service. func NewEntityApp(ha driven.HAClient) *EntityApp { return &EntityApp{ha: ha} } +// GetState loads a single entity state through the HA driven port. func (a *EntityApp) GetState(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { s, err := a.ha.GetState(ctx, string(id)) if err != nil { @@ -25,6 +27,7 @@ func (a *EntityApp) GetState(ctx context.Context, id domain.EntityID) (*domain.E return haStateToDomain(s), nil } +// ListStates filters the full Home Assistant state list by explicit IDs and/or domain. func (a *EntityApp) ListStates(ctx context.Context, ids []domain.EntityID, domainFilter string) ([]*domain.EntityState, error) { all, err := a.ha.ListStates(ctx) if err != nil { @@ -53,6 +56,8 @@ func (a *EntityApp) ListStates(ctx context.Context, ids []domain.EntityID, domai return out, nil } +// haStateToDomain stringifies raw Home Assistant attributes so the core model +// stays transport-agnostic and easy to serialize over gRPC. func haStateToDomain(s *driven.HAState) *domain.EntityState { attrs := make(map[string]string, len(s.Attributes)) for k, v := range s.Attributes { diff --git a/ha-gateway/internal/app/light.go b/ha-gateway/internal/app/light.go index bbb0f4d..57ad724 100644 --- a/ha-gateway/internal/app/light.go +++ b/ha-gateway/internal/app/light.go @@ -15,10 +15,12 @@ type LightApp struct { cache []domain.Light } +// NewLightApp constructs the light application service. func NewLightApp(ha driven.HAClient) *LightApp { return &LightApp{ha: ha} } +// Refresh repopulates the light cache from the full Home Assistant state list. func (a *LightApp) Refresh(ctx context.Context) error { all, err := a.ha.ListStates(ctx) if err != nil { @@ -39,6 +41,7 @@ func (a *LightApp) Refresh(ctx context.Context) error { return nil } +// ListLights returns cached light discovery data, refreshing lazily on first use. func (a *LightApp) ListLights(ctx context.Context) ([]domain.Light, error) { a.mu.RLock() c := a.cache @@ -54,6 +57,7 @@ func (a *LightApp) ListLights(ctx context.Context) ([]domain.Light, error) { return c, nil } +// TurnOn maps application parameters into a Home Assistant light.turn_on call. func (a *LightApp) TurnOn(ctx context.Context, p domain.TurnOnParams) (*domain.EntityState, error) { payload := map[string]any{"entity_id": string(p.EntityID)} if p.BrightnessPct != nil { @@ -71,6 +75,7 @@ func (a *LightApp) TurnOn(ctx context.Context, p domain.TurnOnParams) (*domain.E return a.callService(ctx, "light", "turn_on", payload) } +// TurnOff maps application parameters into a Home Assistant light.turn_off call. func (a *LightApp) TurnOff(ctx context.Context, p domain.TurnOffParams) (*domain.EntityState, error) { payload := map[string]any{"entity_id": string(p.EntityID)} if p.Transition != nil { @@ -79,11 +84,14 @@ func (a *LightApp) TurnOff(ctx context.Context, p domain.TurnOffParams) (*domain return a.callService(ctx, "light", "turn_off", payload) } +// Toggle maps directly to Home Assistant light.toggle. func (a *LightApp) Toggle(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { payload := map[string]any{"entity_id": string(id)} return a.callService(ctx, "light", "toggle", payload) } +// callService falls back to GetState because Home Assistant may succeed without +// returning a full entity state list for the service call response. func (a *LightApp) callService(ctx context.Context, svcDomain, service string, payload map[string]any) (*domain.EntityState, error) { states, err := a.ha.CallService(ctx, svcDomain, service, payload) if err != nil { @@ -103,6 +111,7 @@ func (a *LightApp) callService(ctx context.Context, svcDomain, service string, p return haStateToDomain(s), nil } +// haStateToLight extracts the subset of attributes needed for discovery clients. func haStateToLight(s *driven.HAState) domain.Light { l := domain.Light{ EntityID: domain.EntityID(s.EntityID), diff --git a/ha-gateway/internal/app/switch.go b/ha-gateway/internal/app/switch.go index b8b942f..dababba 100644 --- a/ha-gateway/internal/app/switch.go +++ b/ha-gateway/internal/app/switch.go @@ -15,10 +15,12 @@ type SwitchApp struct { cache []domain.Switch } +// NewSwitchApp constructs the switch application service. func NewSwitchApp(ha driven.HAClient) *SwitchApp { return &SwitchApp{ha: ha} } +// Refresh repopulates the switch cache from the full Home Assistant state list. func (a *SwitchApp) Refresh(ctx context.Context) error { all, err := a.ha.ListStates(ctx) if err != nil { @@ -39,6 +41,7 @@ func (a *SwitchApp) Refresh(ctx context.Context) error { return nil } +// ListSwitches returns cached switch discovery data, refreshing lazily on first use. func (a *SwitchApp) ListSwitches(ctx context.Context) ([]domain.Switch, error) { a.mu.RLock() c := a.cache @@ -54,6 +57,7 @@ func (a *SwitchApp) ListSwitches(ctx context.Context) ([]domain.Switch, error) { return c, nil } +// haStateToSwitch extracts the subset of attributes needed for switch discovery. func haStateToSwitch(s *driven.HAState) domain.Switch { sw := domain.Switch{ EntityID: domain.EntityID(s.EntityID), diff --git a/ha-gateway/internal/config/config.go b/ha-gateway/internal/config/config.go index ec1ac7e..d05f6a3 100644 --- a/ha-gateway/internal/config/config.go +++ b/ha-gateway/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" ) +// Config holds runtime configuration for the Home Assistant gRPC gateway. type Config struct { GRPCPort string // GRPC_PORT, default "50051" HABaseURL string // HA_BASE_URL, e.g. "http://ha.home.arpa:8123" @@ -15,6 +16,7 @@ type Config struct { // empty = telemetry disabled (local dev default) } +// Load reads configuration from environment variables and applies defaults. func Load() (*Config, error) { token := os.Getenv("HA_TOKEN") if token == "" { @@ -36,6 +38,7 @@ func Load() (*Config, error) { }, nil } +// getenvDefault keeps config loading concise for optional variables with defaults. func getenvDefault(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/ha-gateway/internal/core/domain/entity.go b/ha-gateway/internal/core/domain/entity.go index 3caf677..8f3479c 100644 --- a/ha-gateway/internal/core/domain/entity.go +++ b/ha-gateway/internal/core/domain/entity.go @@ -5,14 +5,23 @@ import ( "time" ) +// EntityID is the canonical Home Assistant entity identifier, for example +// "light.living_room". type EntityID string +// EntityState is the normalized entity snapshot returned by the gateway. type EntityState struct { - EntityID EntityID - State string - Attributes map[string]string + // EntityID is the Home Assistant entity identifier. + EntityID EntityID + // State is the current raw Home Assistant state string. + State string + // Attributes contains stringified Home Assistant attributes for transport. + Attributes map[string]string + // LastChanged is when the state last changed in Home Assistant. LastChanged time.Time + // LastUpdated is when any part of the entity state last updated. LastUpdated time.Time } +// ErrNotImplemented marks domain operations that are intentionally stubbed. var ErrNotImplemented = errors.New("not implemented") diff --git a/ha-gateway/internal/core/domain/light.go b/ha-gateway/internal/core/domain/light.go index 9384745..5ecf397 100644 --- a/ha-gateway/internal/core/domain/light.go +++ b/ha-gateway/internal/core/domain/light.go @@ -1,38 +1,62 @@ package domain +// ColorMode describes a Home Assistant light color capability. type ColorMode string const ( - ColorModeColorTemp ColorMode = "color_temp" - ColorModeHS ColorMode = "hs" - ColorModeXY ColorMode = "xy" + // ColorModeColorTemp indicates color temperature control in kelvin. + ColorModeColorTemp ColorMode = "color_temp" + // ColorModeHS indicates hue/saturation color control. + ColorModeHS ColorMode = "hs" + // ColorModeXY indicates XY color control. + ColorModeXY ColorMode = "xy" + // ColorModeBrightness indicates brightness-only control. ColorModeBrightness ColorMode = "brightness" ) +// Light represents a discovered light entity with presentation-friendly fields. type Light struct { - EntityID EntityID - FriendlyName string - State string // "on" | "off" | "unavailable" + // EntityID is the Home Assistant entity identifier. + EntityID EntityID + // FriendlyName is the user-facing name from Home Assistant attributes. + FriendlyName string + // State is the current raw light state, usually on, off, or unavailable. + State string // "on" | "off" | "unavailable" + // SupportedColorModes mirrors Home Assistant supported_color_modes. SupportedColorModes []ColorMode - MinColorTempKelvin uint32 - MaxColorTempKelvin uint32 - IsHueGroup bool - EffectList []string + // MinColorTempKelvin is the lower supported color temperature bound. + MinColorTempKelvin uint32 + // MaxColorTempKelvin is the upper supported color temperature bound. + MaxColorTempKelvin uint32 + // IsHueGroup marks synthetic Hue group entities. + IsHueGroup bool + // EffectList contains supported named effects when exposed by Home Assistant. + EffectList []string } +// TurnOnParams collects optional light turn-on parameters. type TurnOnParams struct { - EntityID EntityID - BrightnessPct *uint32 + // EntityID is the target light entity. + EntityID EntityID + // BrightnessPct is nil when brightness should be left unchanged. + BrightnessPct *uint32 + // ColorTempKelvin is nil when color temperature should be left unchanged. ColorTempKelvin *uint32 - RGBColor *RGBColor - Transition *uint32 + // RGBColor is nil when RGB color should be left unchanged. + RGBColor *RGBColor + // Transition is nil when no explicit transition is requested. + Transition *uint32 } +// RGBColor represents an RGB color payload for Home Assistant light calls. type RGBColor struct { R, G, B uint8 } +// TurnOffParams collects optional light turn-off parameters. type TurnOffParams struct { - EntityID EntityID + // EntityID is the target light entity. + EntityID EntityID + // Transition is nil when no explicit transition is requested. Transition *uint32 } diff --git a/ha-gateway/internal/core/domain/switch.go b/ha-gateway/internal/core/domain/switch.go index 3357e18..b5ec78a 100644 --- a/ha-gateway/internal/core/domain/switch.go +++ b/ha-gateway/internal/core/domain/switch.go @@ -1,8 +1,13 @@ package domain +// Switch represents a discovered switch entity. type Switch struct { - EntityID EntityID + // EntityID is the Home Assistant entity identifier. + EntityID EntityID + // FriendlyName is the user-facing name from Home Assistant attributes. FriendlyName string - State string // "on" | "off" | "unavailable" - DeviceClass string // e.g. "switch" + // State is the current raw switch state, usually on, off, or unavailable. + State string // "on" | "off" | "unavailable" + // DeviceClass identifies the switch semantic type when Home Assistant sets it. + DeviceClass string // e.g. "switch" } diff --git a/ha-gateway/internal/core/ports/driven/ha.go b/ha-gateway/internal/core/ports/driven/ha.go index f0589f0..93eb437 100644 --- a/ha-gateway/internal/core/ports/driven/ha.go +++ b/ha-gateway/internal/core/ports/driven/ha.go @@ -6,17 +6,25 @@ import ( ) type HAClient interface { + // GetState fetches the current state for one Home Assistant entity. GetState(ctx context.Context, entityID string) (*HAState, error) + // ListStates fetches the full Home Assistant state list for discovery/filtering. ListStates(ctx context.Context) ([]*HAState, error) + // CallService invokes a Home Assistant domain/service pair with a JSON payload. CallService(ctx context.Context, domain, service string, payload map[string]any) ([]*HAState, error) } // HAState mirrors the HA REST response. Internal type — not exposed outside // the driven port layer. type HAState struct { - EntityID string - State string - Attributes map[string]any + // EntityID is the Home Assistant entity identifier. + EntityID string + // State is the raw Home Assistant state string. + State string + // Attributes preserves the raw Home Assistant attribute values for mapping. + Attributes map[string]any + // LastChanged is when the state last changed in Home Assistant. LastChanged time.Time + // LastUpdated is when any part of the state last updated in Home Assistant. LastUpdated time.Time } diff --git a/ha-gateway/internal/core/ports/driving/entity.go b/ha-gateway/internal/core/ports/driving/entity.go index a2c4aa1..deb5e8f 100644 --- a/ha-gateway/internal/core/ports/driving/entity.go +++ b/ha-gateway/internal/core/ports/driving/entity.go @@ -7,6 +7,8 @@ import ( ) type EntityService interface { + // GetState returns the current state for one entity identifier. GetState(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + // ListStates returns entity states filtered by explicit IDs and/or domain. ListStates(ctx context.Context, ids []domain.EntityID, domainFilter string) ([]*domain.EntityState, error) } diff --git a/ha-gateway/internal/core/ports/driving/light.go b/ha-gateway/internal/core/ports/driving/light.go index 99364b9..6e7a1aa 100644 --- a/ha-gateway/internal/core/ports/driving/light.go +++ b/ha-gateway/internal/core/ports/driving/light.go @@ -7,9 +7,14 @@ import ( ) type LightService interface { + // TurnOn turns on a light and returns the resulting entity state. TurnOn(ctx context.Context, p domain.TurnOnParams) (*domain.EntityState, error) + // TurnOff turns off a light and returns the resulting entity state. TurnOff(ctx context.Context, p domain.TurnOffParams) (*domain.EntityState, error) + // Toggle toggles a light and returns the resulting entity state. Toggle(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + // ListLights returns cached light metadata for discovery and UI use. ListLights(ctx context.Context) ([]domain.Light, error) + // Refresh repopulates the light discovery cache from Home Assistant states. Refresh(ctx context.Context) error } diff --git a/ha-gateway/internal/core/ports/driving/switch.go b/ha-gateway/internal/core/ports/driving/switch.go index 22fff89..2d95821 100644 --- a/ha-gateway/internal/core/ports/driving/switch.go +++ b/ha-gateway/internal/core/ports/driving/switch.go @@ -7,6 +7,8 @@ import ( ) type SwitchService interface { + // ListSwitches returns cached switch metadata for discovery and UI use. ListSwitches(ctx context.Context) ([]domain.Switch, error) + // Refresh repopulates the switch discovery cache from Home Assistant states. Refresh(ctx context.Context) error } diff --git a/ha-gateway/internal/logger/logger.go b/ha-gateway/internal/logger/logger.go index 2232a97..498bf86 100644 --- a/ha-gateway/internal/logger/logger.go +++ b/ha-gateway/internal/logger/logger.go @@ -9,6 +9,7 @@ import ( type contextKey struct{} +// New constructs a root logger for the configured format and level. func New(format, level string) *slog.Logger { var parsed slog.Level if err := parsed.UnmarshalText([]byte(level)); err != nil { @@ -23,10 +24,12 @@ func New(format, level string) *slog.Logger { return slog.New(slog.NewTextHandler(os.Stdout, opts)) } +// WithLogger attaches a logger to the provided context. func WithLogger(ctx context.Context, l *slog.Logger) context.Context { return context.WithValue(ctx, contextKey{}, l) } +// FromContext retrieves a logger from context and falls back to slog.Default(). func FromContext(ctx context.Context) *slog.Logger { if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil { return l diff --git a/ha-gateway/internal/telemetry/telemetry.go b/ha-gateway/internal/telemetry/telemetry.go index f6aee62..e243595 100644 --- a/ha-gateway/internal/telemetry/telemetry.go +++ b/ha-gateway/internal/telemetry/telemetry.go @@ -20,6 +20,7 @@ import ( "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config" ) +// Setup initialises OTel trace and metric providers for one service. func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) (shutdown func(context.Context) error, err error) { if cfg.OTELEndpoint == "" { otel.SetTracerProvider(tracenoop.NewTracerProvider()) @@ -69,6 +70,7 @@ func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) otel.SetMeterProvider(mp) return func(ctx context.Context) error { + // Shutdown is bounded so exporter flushes cannot stall process exit forever. shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() var shutdownErr error