Enhance Discord bot and HA gateway with improved structure and documentation
All checks were successful
CI / test (push) Successful in 4s
CI / build-ha-gateway (push) Successful in 1m7s
CI / build-discord-bot (push) Successful in 51s

- 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.
This commit is contained in:
Nik Afiq 2026-04-09 06:00:59 +09:00
parent b5592a1705
commit 6ea4e84949
32 changed files with 404 additions and 421 deletions

544
README.md
View File

@ -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 The repo uses hexagonal architecture:
- `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`
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 That means the business logic does not need to know about Discord, protobuf
2. generate client/server code from that definition transport details, or Home Assistant HTTP specifics.
3. let all services use the same generated types so they agree on request and response shape
## What Each Folder Does ### Service relationship
```text ```text
. Discord bot ->\
├── proto/ # Source protobuf service and message definitions \
├── gen/ # Generated Go code from proto/ -> ha-gateway -> Home Assistant
├── ha-gateway/ # gRPC server + Home Assistant REST adapter /
├── discord-bot/ # Discord slash-command app + gRPC client to ha-gateway Alexa bridge ->/
├── buf.yaml # Buf module config
├── buf.gen.yaml # Buf code generation config
└── go.work # Go workspace linking all local modules
``` ```
## 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` This gives every service the same:
- RPC methods like `ListLights`, `TurnOn`, and `ListSwitches`
- message schemas like `TurnOnRequest`, `LightEntity`, and `SwitchEntity`
Those `.proto` files are not directly used by Go at runtime. They are compiled - RPC method names
into Go source files in `gen/ha/v1/`, such as: - request and response message types
- protobuf serialization rules
- gRPC client/server bindings
- `light.pb.go` ## Services
- `light_grpc.pb.go`
- `switch.pb.go`
- `switch_grpc.pb.go`
That generated code gives you: ### ha-gateway
- Go structs for protobuf messages `ha-gateway` is the single internal gRPC gateway to Home Assistant. It should
- gRPC client interfaces for callers not be exposed publicly.
- gRPC server interfaces and registration helpers for servers
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 Config:
generated gRPC clients like `hav1.LightServiceClient`
- `ha-gateway` imports the same package to register generated gRPC servers like
`hav1.RegisterLightServiceServer(...)`
Without `gen/`, both services would need to hand-write and manually keep in | Variable | Default | Description |
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 |
| --- | --- | --- | | --- | --- | --- |
| `DISCORD_TOKEN` | yes | Discord bot token | | `GRPC_PORT` | `50051` | gRPC listen port |
| `GUILD_ID` | no | Guild command registration target | | `HA_BASE_URL` | empty | Base URL for Home Assistant |
| `HA_GATEWAY_ADDR` | yes | gRPC address of `ha-gateway` | | `HA_TOKEN` | none | Home Assistant long-lived access token |
| `OTEL_ENDPOINT` | no | OTLP gRPC endpoint | | `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 ```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 buf generate
```
That regenerates `gen/ha/v1/*.pb.go` from `proto/ha/v1/*.proto`. # Configure ha-gateway
## 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
cp ha-gateway/.env.example ha-gateway/.env cp ha-gateway/.env.example ha-gateway/.env
cd ha-gateway # Edit ha-gateway/.env — set HA_BASE_URL and HA_TOKEN
go run ./cmd/gateway
# 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 ```bash
cp discord-bot/.env.example discord-bot/.env # List all lights
cd discord-bot grpcurl -plaintext -d '{"domain":"light"}' \
go run ./cmd/bot localhost:50051 ha.v1.EntityService/ListStates
```
## 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
# Turn on a light
grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' \ grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' \
localhost:50051 ha.v1.LightService/TurnOn localhost:50051 ha.v1.LightService/TurnOn
grpcurl -plaintext -d '{}' \
localhost:50051 ha.v1.SwitchService/ListSwitches
``` ```
## Docker ## Building
Build from the repo root:
```bash ```bash
# Build ha-gateway image (run from repo root)
docker build -f ha-gateway/Dockerfile -t ha-gateway:dev . docker build -f ha-gateway/Dockerfile -t ha-gateway:dev .
# Build discord-bot image
docker build -f discord-bot/Dockerfile -t discord-bot:dev . docker build -f discord-bot/Dockerfile -t discord-bot:dev .
``` ```
## Telemetry ## What's Next
Both runtime services support OTLP/gRPC telemetry: - Auth: mTLS between services using an internal cert-manager CA
- SwitchService control implementation
- `ha-gateway` uses service name `ha-gateway` - EventService with Home Assistant WebSocket fan-out broker
- `discord-bot` uses service name `discord-bot` - `alexa-bridge` implementation
- Gitea CI pipeline for automated build and push
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

View File

@ -23,6 +23,7 @@ var version = "dev"
func main() { func main() {
_ = godotenv.Load() _ = godotenv.Load()
// Config is loaded before logger setup so fatal startup errors still stop early.
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
os.Stderr.WriteString("config error: " + err.Error() + "\n") os.Stderr.WriteString("config error: " + err.Error() + "\n")
@ -42,6 +43,7 @@ func main() {
defer stop() defer stop()
ctx = logger.WithLogger(ctx, log) 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) shutdown, err := telemetry.Setup(ctx, "discord-bot", version, cfg)
if err != nil { if err != nil {
log.Error("telemetry setup failed", "err", err) log.Error("telemetry setup failed", "err", err)
@ -66,6 +68,7 @@ func main() {
commandApp := app.NewCommandApp(haClient) 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) session, err := discordgo.New("Bot " + cfg.DiscordToken)
if err != nil { if err != nil {
log.Error("create discord session failed", "err", err) 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 { func redactToken(token string) string {
if token == "" { if token == "" {
return "[not set]" return "[not set]"

View File

@ -27,18 +27,23 @@ type commandHandler interface {
AutocompleteSwitches(ctx context.Context) ([]apppkg.Choice, error) AutocompleteSwitches(ctx context.Context) ([]apppkg.Choice, error)
} }
// Handler adapts Discord interactions to the command application layer.
type Handler struct { type Handler struct {
app commandHandler app commandHandler
} }
// NewHandler constructs the Discord interaction adapter.
func NewHandler(app commandHandler) *Handler { func NewHandler(app commandHandler) *Handler {
return &Handler{app: app} return &Handler{app: app}
} }
// Register attaches the interaction handler to the Discord session.
func (h *Handler) Register(s *discordgo.Session) { func (h *Handler) Register(s *discordgo.Session) {
s.AddHandler(h.onInteractionCreate) 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) { func (h *Handler) onInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() 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) { func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
log := logger.FromContext(ctx) log := logger.FromContext(ctx)
start := time.Now() 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) { func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
log := logger.FromContext(ctx) log := logger.FromContext(ctx)
data := i.ApplicationCommandData() 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 { func (h *Handler) deferResponse(s *discordgo.Session, interaction *discordgo.Interaction, ephemeral bool) error {
data := &discordgo.InteractionResponseData{} data := &discordgo.InteractionResponseData{}
if ephemeral { if ephemeral {
@ -194,6 +204,7 @@ func (h *Handler) deferResponse(s *discordgo.Session, interaction *discordgo.Int
return nil 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) { func (h *Handler) respondMessage(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, content string, ephemeral bool) {
log := logger.FromContext(ctx) log := logger.FromContext(ctx)
data := &discordgo.InteractionResponseData{Content: content} 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) { 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", logger.FromContext(ctx).Error("command failed",
"duration_ms", time.Since(start).Milliseconds(), "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) 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) { 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) log := logger.FromContext(ctx)
if err != nil { 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 { func interactionLogger(ctx context.Context, i *discordgo.InteractionCreate) *slog.Logger {
log := logger.FromContext(ctx) log := logger.FromContext(ctx)
data := i.ApplicationCommandData() data := i.ApplicationCommandData()

View File

@ -6,6 +6,7 @@ import (
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
// RegisterCommands upserts the bot's slash command definitions.
func RegisterCommands(s *discordgo.Session, guildID string) error { func RegisterCommands(s *discordgo.Session, guildID string) error {
appID := s.State.User.ID appID := s.State.User.ID
if appID == "" { if appID == "" {
@ -88,6 +89,7 @@ func RegisterCommands(s *discordgo.Session, guildID string) error {
return nil return nil
} }
// lightOption centralizes the shared light entity selector used by subcommands.
func lightOption() *discordgo.ApplicationCommandOption { func lightOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{ return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionString, 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 { func ptrFloat(v float64) *float64 {
return &v return &v
} }

View File

@ -14,6 +14,7 @@ import (
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
) )
// Client implements the app's HA driven port over gRPC.
type Client struct { type Client struct {
conn *grpc.ClientConn conn *grpc.ClientConn
lightClient hav1.LightServiceClient lightClient hav1.LightServiceClient
@ -21,6 +22,7 @@ type Client struct {
log *slog.Logger 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) { func New(ctx context.Context, addr string, log *slog.Logger) (*Client, error) {
conn, err := grpc.NewClient( conn, err := grpc.NewClient(
addr, addr,
@ -39,6 +41,7 @@ func New(ctx context.Context, addr string, log *slog.Logger) (*Client, error) {
}, nil }, nil
} }
// Close closes the underlying gRPC connection.
func (c *Client) Close() error { func (c *Client) Close() error {
if err := c.conn.Close(); err != nil { if err := c.conn.Close(); err != nil {
return fmt.Errorf("close ha-gateway client: %w", err) return fmt.Errorf("close ha-gateway client: %w", err)
@ -46,6 +49,8 @@ func (c *Client) Close() error {
return nil 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) { func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) {
start := time.Now() start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/ListLights") 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 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) { func (c *Client) ListSwitches(ctx context.Context) ([]driven.Switch, error) {
start := time.Now() start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "SwitchService/ListSwitches") 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 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 { func (c *Client) TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error {
start := time.Now() start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/TurnOn") 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 return nil
} }
// TurnOffLight forwards a light turn-off request over gRPC.
func (c *Client) TurnOffLight(ctx context.Context, entityID string, transition *uint32) error { func (c *Client) TurnOffLight(ctx context.Context, entityID string, transition *uint32) error {
start := time.Now() start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/TurnOff") 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 return nil
} }
// ToggleLight forwards a light toggle request over gRPC.
func (c *Client) ToggleLight(ctx context.Context, entityID string) error { func (c *Client) ToggleLight(ctx context.Context, entityID string) error {
start := time.Now() start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/Toggle") log := logger.FromContext(ctx).With("grpc.method", "LightService/Toggle")

View File

@ -9,19 +9,23 @@ import (
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven" "gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven"
) )
// Choice is one Discord autocomplete entry.
type Choice struct { type Choice struct {
Label string Label string
Value string Value string
} }
// CommandApp orchestrates Discord command use cases against ha-gateway.
type CommandApp struct { type CommandApp struct {
ha driven.HAGateway ha driven.HAGateway
} }
// NewCommandApp constructs the Discord command application service.
func NewCommandApp(ha driven.HAGateway) *CommandApp { func NewCommandApp(ha driven.HAGateway) *CommandApp {
return &CommandApp{ha: ha} return &CommandApp{ha: ha}
} }
// HandleLightList formats discovered lights into a monospace-friendly response.
func (a *CommandApp) HandleLightList(ctx context.Context) (string, error) { func (a *CommandApp) HandleLightList(ctx context.Context) (string, error) {
lights, err := a.ha.ListLights(ctx) lights, err := a.ha.ListLights(ctx)
if err != nil { if err != nil {
@ -40,6 +44,7 @@ func (a *CommandApp) HandleLightList(ctx context.Context) (string, error) {
return strings.Join(lines, "\n"), nil 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) { func (a *CommandApp) HandleLightOn(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) (string, error) {
name, err := a.lookupLightName(ctx, entityID) name, err := a.lookupLightName(ctx, entityID)
if err != nil { 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 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) { func (a *CommandApp) HandleLightOff(ctx context.Context, entityID string, transition *uint32) (string, error) {
name, err := a.lookupLightName(ctx, entityID) name, err := a.lookupLightName(ctx, entityID)
if err != nil { 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 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) { func (a *CommandApp) HandleLightToggle(ctx context.Context, entityID string) (string, error) {
name, err := a.lookupLightName(ctx, entityID) name, err := a.lookupLightName(ctx, entityID)
if err != nil { if err != nil {
@ -87,6 +94,7 @@ func (a *CommandApp) HandleLightToggle(ctx context.Context, entityID string) (st
return fmt.Sprintf("Toggled `%s`.", name), nil 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) { func (a *CommandApp) HandleSwitchList(ctx context.Context) (string, error) {
switches, err := a.ha.ListSwitches(ctx) switches, err := a.ha.ListSwitches(ctx)
if err != nil { if err != nil {
@ -113,6 +121,7 @@ func (a *CommandApp) HandleSwitchList(ctx context.Context) (string, error) {
return strings.Join(lines, "\n"), nil return strings.Join(lines, "\n"), nil
} }
// AutocompleteLights maps discovered lights into Discord autocomplete choices.
func (a *CommandApp) AutocompleteLights(ctx context.Context) ([]Choice, error) { func (a *CommandApp) AutocompleteLights(ctx context.Context) ([]Choice, error) {
lights, err := a.ha.ListLights(ctx) lights, err := a.ha.ListLights(ctx)
if err != nil { if err != nil {
@ -130,6 +139,7 @@ func (a *CommandApp) AutocompleteLights(ctx context.Context) ([]Choice, error) {
return choices, nil return choices, nil
} }
// AutocompleteSwitches maps discovered switches into Discord autocomplete choices.
func (a *CommandApp) AutocompleteSwitches(ctx context.Context) ([]Choice, error) { func (a *CommandApp) AutocompleteSwitches(ctx context.Context) ([]Choice, error) {
switches, err := a.ha.ListSwitches(ctx) switches, err := a.ha.ListSwitches(ctx)
if err != nil { if err != nil {
@ -147,6 +157,8 @@ func (a *CommandApp) AutocompleteSwitches(ctx context.Context) ([]Choice, error)
return choices, nil 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) { func (a *CommandApp) lookupLightName(ctx context.Context, entityID string) (string, error) {
lights, err := a.ha.ListLights(ctx) lights, err := a.ha.ListLights(ctx)
if err != nil { if err != nil {
@ -164,6 +176,8 @@ func (a *CommandApp) lookupLightName(ctx context.Context, entityID string) (stri
return lights[idx].FriendlyName, nil 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 { func formatLightLine(light driven.Light) string {
label := light.FriendlyName label := light.FriendlyName
if label == "" { 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) 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 { func stateEmoji(state string) string {
switch state { switch state {
case "on": case "on":

View File

@ -5,6 +5,7 @@ import (
"os" "os"
) )
// Config holds runtime configuration for the Discord bot process.
type Config struct { type Config struct {
DiscordToken string DiscordToken string
GuildID string GuildID string
@ -14,6 +15,7 @@ type Config struct {
LogFormat string LogFormat string
} }
// Load reads Discord bot configuration from environment variables.
func Load() (*Config, error) { func Load() (*Config, error) {
token := os.Getenv("DISCORD_TOKEN") token := os.Getenv("DISCORD_TOKEN")
if token == "" { if token == "" {
@ -35,6 +37,7 @@ func Load() (*Config, error) {
}, nil }, nil
} }
// getenvDefault keeps config loading concise for optional variables with defaults.
func getenvDefault(key, fallback string) string { func getenvDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v

View File

@ -3,27 +3,46 @@ package driven
import "context" import "context"
type HAGateway interface { type HAGateway interface {
// ListLights returns light discovery data from ha-gateway.
ListLights(ctx context.Context) ([]Light, error) ListLights(ctx context.Context) ([]Light, error)
// ListSwitches returns switch discovery data from ha-gateway.
ListSwitches(ctx context.Context) ([]Switch, error) 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 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 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 ToggleLight(ctx context.Context, entityID string) error
} }
// Light is the discovery-oriented light view exposed by ha-gateway.
type Light struct { type Light struct {
EntityID string // EntityID is the Home Assistant entity identifier.
FriendlyName string EntityID string
State 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 SupportedColorModes []string
MinColorTempKelvin uint32 // MinColorTempKelvin is the lower supported color temperature bound.
MaxColorTempKelvin uint32 MinColorTempKelvin uint32
IsHueGroup bool // MaxColorTempKelvin is the upper supported color temperature bound.
EffectList []string 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 { 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 FriendlyName string
State string // State is the raw switch state string.
DeviceClass string State string
// DeviceClass describes the semantic type when Home Assistant provides it.
DeviceClass string
} }

View File

@ -9,6 +9,7 @@ import (
type contextKey struct{} type contextKey struct{}
// New constructs a root logger for the configured format and level.
func New(format, level string) *slog.Logger { func New(format, level string) *slog.Logger {
var parsed slog.Level var parsed slog.Level
if err := parsed.UnmarshalText([]byte(level)); err != nil { 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)) 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 { func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, contextKey{}, l) 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 { func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil { if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil {
return l return l

View File

@ -20,6 +20,7 @@ import (
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/config" "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) { func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) (shutdown func(context.Context) error, err error) {
if cfg.OTELEndpoint == "" { if cfg.OTELEndpoint == "" {
otel.SetTracerProvider(tracenoop.NewTracerProvider()) otel.SetTracerProvider(tracenoop.NewTracerProvider())
@ -68,6 +69,7 @@ func Setup(ctx context.Context, serviceName, version string, cfg *config.Config)
otel.SetMeterProvider(mp) otel.SetMeterProvider(mp)
return func(ctx context.Context) error { 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) shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
var shutdownErr error var shutdownErr error

View File

@ -38,6 +38,7 @@ var version = "dev"
func main() { func main() {
_ = godotenv.Load() _ = godotenv.Load()
// Config is loaded before logger setup so fatal startup errors still stop early.
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
os.Stderr.WriteString("config error: " + err.Error() + "\n") os.Stderr.WriteString("config error: " + err.Error() + "\n")
@ -59,6 +60,7 @@ func main() {
defer stop() defer stop()
ctx = logger.WithLogger(ctx, log) 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) shutdown, err := telemetry.Setup(ctx, "ha-gateway", version, cfg)
if err != nil { if err != nil {
log.Error("telemetry setup failed", "err", err) log.Error("telemetry setup failed", "err", err)
@ -72,6 +74,7 @@ func main() {
haClient := ha.NewClient(cfg, log) haClient := ha.NewClient(cfg, log)
// App services stay free of gRPC and HTTP details; adapters are wired here.
entityApp := app.NewEntityApp(haClient) entityApp := app.NewEntityApp(haClient)
lightApp := app.NewLightApp(haClient) lightApp := app.NewLightApp(haClient)
switchApp := app.NewSwitchApp(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 { func redactToken(token string) string {
if token == "" { if token == "" {
return "[not set]" return "[not set]"

View File

@ -17,10 +17,12 @@ type EntityGRPC struct {
svc driving.EntityService svc driving.EntityService
} }
// NewEntityGRPC constructs the gRPC adapter for EntityService.
func NewEntityGRPC(svc driving.EntityService) *EntityGRPC { func NewEntityGRPC(svc driving.EntityService) *EntityGRPC {
return &EntityGRPC{svc: svc} 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) { func (h *EntityGRPC) GetState(ctx context.Context, req *hav1.GetStateRequest) (*hav1.GetStateResponse, error) {
s, err := h.svc.GetState(ctx, domain.EntityID(req.EntityId)) s, err := h.svc.GetState(ctx, domain.EntityID(req.EntityId))
if err != nil { if err != nil {
@ -29,6 +31,7 @@ func (h *EntityGRPC) GetState(ctx context.Context, req *hav1.GetStateRequest) (*
return &hav1.GetStateResponse{State: domainStateToProto(s)}, nil 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) { func (h *EntityGRPC) ListStates(ctx context.Context, req *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
ids := make([]domain.EntityID, len(req.EntityIds)) ids := make([]domain.EntityID, len(req.EntityIds))
for i, id := range req.EntityIds { for i, id := range req.EntityIds {

View File

@ -4,9 +4,9 @@ import (
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
) )
// EventGRPC is the placeholder gRPC adapter for future event streaming.
type EventGRPC struct { type EventGRPC struct {
hav1.UnimplementedEventServiceServer hav1.UnimplementedEventServiceServer
} }
// Subscribe returns codes.Unimplemented via the embedded UnimplementedEventServiceServer. // TODO: implement event fan-out broker wiring — see plan.md for context
// TODO: inject fanout broker here once websocket.go is implemented.

View File

@ -12,6 +12,7 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
// LoggingUnaryInterceptor logs one completion record for each unary gRPC call.
func LoggingUnaryInterceptor(log *slog.Logger) grpc.UnaryServerInterceptor { func LoggingUnaryInterceptor(log *slog.Logger) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
method, ok := grpc.Method(ctx) 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 { func LoggingStreamInterceptor(log *slog.Logger) grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
reqLog := requestLogger(ss.Context(), log, info.FullMethod) reqLog := requestLogger(ss.Context(), log, info.FullMethod)
@ -49,10 +51,13 @@ type loggingServerStream struct {
ctx context.Context ctx context.Context
} }
// Context returns the request-scoped context with the derived logger attached.
func (s *loggingServerStream) Context() context.Context { func (s *loggingServerStream) Context() context.Context {
return s.ctx 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 { func requestLogger(ctx context.Context, log *slog.Logger, method string) *slog.Logger {
peerAddr := "" peerAddr := ""
if p, ok := peer.FromContext(ctx); ok && p.Addr != nil { 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) 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) { func logCompletion(log *slog.Logger, msg string, code codes.Code, duration time.Duration, err error) {
attrs := []any{ attrs := []any{
"duration_ms", duration.Milliseconds(), "duration_ms", duration.Milliseconds(),

View File

@ -13,10 +13,12 @@ type LightGRPC struct {
svc driving.LightService svc driving.LightService
} }
// NewLightGRPC constructs the gRPC adapter for LightService.
func NewLightGRPC(svc driving.LightService) *LightGRPC { func NewLightGRPC(svc driving.LightService) *LightGRPC {
return &LightGRPC{svc: svc} 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) { func (h *LightGRPC) TurnOn(ctx context.Context, req *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
s, err := h.svc.TurnOn(ctx, protoTurnOnToParams(req)) s, err := h.svc.TurnOn(ctx, protoTurnOnToParams(req))
if err != nil { 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 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) { func (h *LightGRPC) TurnOff(ctx context.Context, req *hav1.TurnOffRequest) (*hav1.LightResponse, error) {
s, err := h.svc.TurnOff(ctx, protoTurnOffToParams(req)) s, err := h.svc.TurnOff(ctx, protoTurnOffToParams(req))
if err != nil { 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 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) { func (h *LightGRPC) Toggle(ctx context.Context, req *hav1.ToggleRequest) (*hav1.LightResponse, error) {
s, err := h.svc.Toggle(ctx, domain.EntityID(req.EntityId)) s, err := h.svc.Toggle(ctx, domain.EntityID(req.EntityId))
if err != nil { 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 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) { func (h *LightGRPC) ListLights(ctx context.Context, req *hav1.ListLightsRequest) (*hav1.ListLightsResponse, error) {
lights, err := h.svc.ListLights(ctx) lights, err := h.svc.ListLights(ctx)
if err != nil { if err != nil {

View File

@ -5,6 +5,7 @@ import (
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" "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 { func domainStateToProto(s *domain.EntityState) *hav1.EntityState {
return &hav1.EntityState{ return &hav1.EntityState{
EntityId: string(s.EntityID), 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 { func protoTurnOnToParams(r *hav1.TurnOnRequest) domain.TurnOnParams {
p := domain.TurnOnParams{ p := domain.TurnOnParams{
EntityID: domain.EntityID(r.EntityId), EntityID: domain.EntityID(r.EntityId),
@ -41,6 +44,7 @@ func protoTurnOnToParams(r *hav1.TurnOnRequest) domain.TurnOnParams {
return p return p
} }
// protoTurnOffToParams preserves optional protobuf fields for turn-off calls.
func protoTurnOffToParams(r *hav1.TurnOffRequest) domain.TurnOffParams { func protoTurnOffToParams(r *hav1.TurnOffRequest) domain.TurnOffParams {
p := domain.TurnOffParams{ p := domain.TurnOffParams{
EntityID: domain.EntityID(r.EntityId), EntityID: domain.EntityID(r.EntityId),
@ -52,6 +56,7 @@ func protoTurnOffToParams(r *hav1.TurnOffRequest) domain.TurnOffParams {
return p return p
} }
// domainLightToProto exposes discovery metadata without leaking domain enums.
func domainLightToProto(l domain.Light) *hav1.LightEntity { func domainLightToProto(l domain.Light) *hav1.LightEntity {
modes := make([]string, len(l.SupportedColorModes)) modes := make([]string, len(l.SupportedColorModes))
for i, m := range 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 { func domainSwitchToProto(s domain.Switch) *hav1.SwitchEntity {
return &hav1.SwitchEntity{ return &hav1.SwitchEntity{
EntityId: string(s.EntityID), EntityId: string(s.EntityID),

View File

@ -12,10 +12,12 @@ type SwitchGRPC struct {
svc driving.SwitchService svc driving.SwitchService
} }
// NewSwitchGRPC constructs the gRPC adapter for SwitchService.
func NewSwitchGRPC(svc driving.SwitchService) *SwitchGRPC { func NewSwitchGRPC(svc driving.SwitchService) *SwitchGRPC {
return &SwitchGRPC{svc: svc} 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) { func (h *SwitchGRPC) ListSwitches(ctx context.Context, req *hav1.ListSwitchesRequest) (*hav1.ListSwitchesResponse, error) {
switches, err := h.svc.ListSwitches(ctx) switches, err := h.svc.ListSwitches(ctx)
if err != nil { if err != nil {
@ -28,17 +30,20 @@ func (h *SwitchGRPC) ListSwitches(ctx context.Context, req *hav1.ListSwitchesReq
return &hav1.ListSwitchesResponse{Switches: out}, nil return &hav1.ListSwitchesResponse{Switches: out}, nil
} }
// TurnOn, TurnOff, Toggle — left as Unimplemented for now. // TurnOn is stubbed until switch control orchestration exists in the app layer.
// TODO: implement once app/switch.go has callService support. // TODO: implement switch control RPCs — see plan.md for context
// Follow the same pattern as LightGRPC: payload{"entity_id": ...} → ha.CallService.
func (h *SwitchGRPC) TurnOn(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) { func (h *SwitchGRPC) TurnOn(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
return nil, grpcError(ErrNotImplemented) 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) { func (h *SwitchGRPC) TurnOff(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
return nil, grpcError(ErrNotImplemented) 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) { func (h *SwitchGRPC) Toggle(ctx context.Context, req *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
return nil, grpcError(ErrNotImplemented) return nil, grpcError(ErrNotImplemented)
} }

View File

@ -21,6 +21,7 @@ import (
var tracer = otel.Tracer("ha-gateway/ha-client") var tracer = otel.Tracer("ha-gateway/ha-client")
// Client implements the HA driven port over Home Assistant's REST API.
type Client struct { type Client struct {
baseURL string baseURL string
token string token string
@ -28,6 +29,7 @@ type Client struct {
log *slog.Logger log *slog.Logger
} }
// NewClient constructs a REST client configured for one Home Assistant instance.
func NewClient(cfg *config.Config, log *slog.Logger) *Client { func NewClient(cfg *config.Config, log *slog.Logger) *Client {
return &Client{ return &Client{
baseURL: strings.TrimRight(cfg.HABaseURL, "/"), 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) { func (c *Client) GetState(ctx context.Context, entityID string) (*driven.HAState, error) {
ctx, span := tracer.Start(ctx, "ha.GetState", ctx, span := tracer.Start(ctx, "ha.GetState",
trace.WithAttributes(attribute.String("ha.entity_id", entityID)), 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() 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) { func (c *Client) ListStates(ctx context.Context) ([]*driven.HAState, error) {
ctx, span := tracer.Start(ctx, "ha.ListStates") ctx, span := tracer.Start(ctx, "ha.ListStates")
defer span.End() defer span.End()
@ -74,6 +78,8 @@ func (c *Client) ListStates(ctx context.Context) ([]*driven.HAState, error) {
return out, nil 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) { func (c *Client) CallService(ctx context.Context, domain, service string, payload map[string]any) ([]*driven.HAState, error) {
ctx, span := tracer.Start(ctx, "ha.CallService", ctx, span := tracer.Start(ctx, "ha.CallService",
trace.WithAttributes( trace.WithAttributes(
@ -149,6 +155,7 @@ func (c *Client) CallService(ctx context.Context, domain, service string, payloa
return out, nil 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 { func (c *Client) get(ctx context.Context, path string, dst any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil { if err != nil {
@ -201,6 +208,8 @@ type haStateRaw struct {
LastUpdated string `json:"last_updated"` 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) { func (r *haStateRaw) toDriven() (*driven.HAState, error) {
lc, err := time.Parse(time.RFC3339, r.LastChanged) lc, err := time.Parse(time.RFC3339, r.LastChanged)
if err != nil { if err != nil {

View File

@ -1,7 +1,7 @@
package ha 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. // Auth flow: receive auth_required → send {"type":"auth","access_token":"..."} → receive auth_ok.
// Subscribe: send {"id":1,"type":"subscribe_events","event_type":"state_changed"} // Subscribe: send {"id":1,"type":"subscribe_events","event_type":"state_changed"}.
// Events: stream {"type":"event","event":{"event_type":"state_changed","data":{...}}} // Events: stream {"type":"event","event":{"event_type":"state_changed","data":{...}}}.
// This adapter will publish to the internal fanout broker once EventService is implemented. // This adapter should publish to an internal fanout broker once EventService is implemented.

View File

@ -13,10 +13,12 @@ type EntityApp struct {
ha driven.HAClient ha driven.HAClient
} }
// NewEntityApp constructs the entity application service.
func NewEntityApp(ha driven.HAClient) *EntityApp { func NewEntityApp(ha driven.HAClient) *EntityApp {
return &EntityApp{ha: ha} 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) { func (a *EntityApp) GetState(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
s, err := a.ha.GetState(ctx, string(id)) s, err := a.ha.GetState(ctx, string(id))
if err != nil { if err != nil {
@ -25,6 +27,7 @@ func (a *EntityApp) GetState(ctx context.Context, id domain.EntityID) (*domain.E
return haStateToDomain(s), nil 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) { func (a *EntityApp) ListStates(ctx context.Context, ids []domain.EntityID, domainFilter string) ([]*domain.EntityState, error) {
all, err := a.ha.ListStates(ctx) all, err := a.ha.ListStates(ctx)
if err != nil { if err != nil {
@ -53,6 +56,8 @@ func (a *EntityApp) ListStates(ctx context.Context, ids []domain.EntityID, domai
return out, nil 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 { func haStateToDomain(s *driven.HAState) *domain.EntityState {
attrs := make(map[string]string, len(s.Attributes)) attrs := make(map[string]string, len(s.Attributes))
for k, v := range s.Attributes { for k, v := range s.Attributes {

View File

@ -15,10 +15,12 @@ type LightApp struct {
cache []domain.Light cache []domain.Light
} }
// NewLightApp constructs the light application service.
func NewLightApp(ha driven.HAClient) *LightApp { func NewLightApp(ha driven.HAClient) *LightApp {
return &LightApp{ha: ha} return &LightApp{ha: ha}
} }
// Refresh repopulates the light cache from the full Home Assistant state list.
func (a *LightApp) Refresh(ctx context.Context) error { func (a *LightApp) Refresh(ctx context.Context) error {
all, err := a.ha.ListStates(ctx) all, err := a.ha.ListStates(ctx)
if err != nil { if err != nil {
@ -39,6 +41,7 @@ func (a *LightApp) Refresh(ctx context.Context) error {
return nil return nil
} }
// ListLights returns cached light discovery data, refreshing lazily on first use.
func (a *LightApp) ListLights(ctx context.Context) ([]domain.Light, error) { func (a *LightApp) ListLights(ctx context.Context) ([]domain.Light, error) {
a.mu.RLock() a.mu.RLock()
c := a.cache c := a.cache
@ -54,6 +57,7 @@ func (a *LightApp) ListLights(ctx context.Context) ([]domain.Light, error) {
return c, nil 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) { func (a *LightApp) TurnOn(ctx context.Context, p domain.TurnOnParams) (*domain.EntityState, error) {
payload := map[string]any{"entity_id": string(p.EntityID)} payload := map[string]any{"entity_id": string(p.EntityID)}
if p.BrightnessPct != nil { 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) 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) { func (a *LightApp) TurnOff(ctx context.Context, p domain.TurnOffParams) (*domain.EntityState, error) {
payload := map[string]any{"entity_id": string(p.EntityID)} payload := map[string]any{"entity_id": string(p.EntityID)}
if p.Transition != nil { 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) 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) { func (a *LightApp) Toggle(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
payload := map[string]any{"entity_id": string(id)} payload := map[string]any{"entity_id": string(id)}
return a.callService(ctx, "light", "toggle", payload) 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) { 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) states, err := a.ha.CallService(ctx, svcDomain, service, payload)
if err != nil { if err != nil {
@ -103,6 +111,7 @@ func (a *LightApp) callService(ctx context.Context, svcDomain, service string, p
return haStateToDomain(s), nil return haStateToDomain(s), nil
} }
// haStateToLight extracts the subset of attributes needed for discovery clients.
func haStateToLight(s *driven.HAState) domain.Light { func haStateToLight(s *driven.HAState) domain.Light {
l := domain.Light{ l := domain.Light{
EntityID: domain.EntityID(s.EntityID), EntityID: domain.EntityID(s.EntityID),

View File

@ -15,10 +15,12 @@ type SwitchApp struct {
cache []domain.Switch cache []domain.Switch
} }
// NewSwitchApp constructs the switch application service.
func NewSwitchApp(ha driven.HAClient) *SwitchApp { func NewSwitchApp(ha driven.HAClient) *SwitchApp {
return &SwitchApp{ha: ha} return &SwitchApp{ha: ha}
} }
// Refresh repopulates the switch cache from the full Home Assistant state list.
func (a *SwitchApp) Refresh(ctx context.Context) error { func (a *SwitchApp) Refresh(ctx context.Context) error {
all, err := a.ha.ListStates(ctx) all, err := a.ha.ListStates(ctx)
if err != nil { if err != nil {
@ -39,6 +41,7 @@ func (a *SwitchApp) Refresh(ctx context.Context) error {
return nil return nil
} }
// ListSwitches returns cached switch discovery data, refreshing lazily on first use.
func (a *SwitchApp) ListSwitches(ctx context.Context) ([]domain.Switch, error) { func (a *SwitchApp) ListSwitches(ctx context.Context) ([]domain.Switch, error) {
a.mu.RLock() a.mu.RLock()
c := a.cache c := a.cache
@ -54,6 +57,7 @@ func (a *SwitchApp) ListSwitches(ctx context.Context) ([]domain.Switch, error) {
return c, nil return c, nil
} }
// haStateToSwitch extracts the subset of attributes needed for switch discovery.
func haStateToSwitch(s *driven.HAState) domain.Switch { func haStateToSwitch(s *driven.HAState) domain.Switch {
sw := domain.Switch{ sw := domain.Switch{
EntityID: domain.EntityID(s.EntityID), EntityID: domain.EntityID(s.EntityID),

View File

@ -5,6 +5,7 @@ import (
"os" "os"
) )
// Config holds runtime configuration for the Home Assistant gRPC gateway.
type Config struct { type Config struct {
GRPCPort string // GRPC_PORT, default "50051" GRPCPort string // GRPC_PORT, default "50051"
HABaseURL string // HA_BASE_URL, e.g. "http://ha.home.arpa:8123" 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) // empty = telemetry disabled (local dev default)
} }
// Load reads configuration from environment variables and applies defaults.
func Load() (*Config, error) { func Load() (*Config, error) {
token := os.Getenv("HA_TOKEN") token := os.Getenv("HA_TOKEN")
if token == "" { if token == "" {
@ -36,6 +38,7 @@ func Load() (*Config, error) {
}, nil }, nil
} }
// getenvDefault keeps config loading concise for optional variables with defaults.
func getenvDefault(key, fallback string) string { func getenvDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v

View File

@ -5,14 +5,23 @@ import (
"time" "time"
) )
// EntityID is the canonical Home Assistant entity identifier, for example
// "light.living_room".
type EntityID string type EntityID string
// EntityState is the normalized entity snapshot returned by the gateway.
type EntityState struct { type EntityState struct {
EntityID EntityID // EntityID is the Home Assistant entity identifier.
State string EntityID EntityID
Attributes map[string]string // 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 LastChanged time.Time
// LastUpdated is when any part of the entity state last updated.
LastUpdated time.Time LastUpdated time.Time
} }
// ErrNotImplemented marks domain operations that are intentionally stubbed.
var ErrNotImplemented = errors.New("not implemented") var ErrNotImplemented = errors.New("not implemented")

View File

@ -1,38 +1,62 @@
package domain package domain
// ColorMode describes a Home Assistant light color capability.
type ColorMode string type ColorMode string
const ( const (
ColorModeColorTemp ColorMode = "color_temp" // ColorModeColorTemp indicates color temperature control in kelvin.
ColorModeHS ColorMode = "hs" ColorModeColorTemp ColorMode = "color_temp"
ColorModeXY ColorMode = "xy" // 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" ColorModeBrightness ColorMode = "brightness"
) )
// Light represents a discovered light entity with presentation-friendly fields.
type Light struct { type Light struct {
EntityID EntityID // EntityID is the Home Assistant entity identifier.
FriendlyName string EntityID EntityID
State string // "on" | "off" | "unavailable" // 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 SupportedColorModes []ColorMode
MinColorTempKelvin uint32 // MinColorTempKelvin is the lower supported color temperature bound.
MaxColorTempKelvin uint32 MinColorTempKelvin uint32
IsHueGroup bool // MaxColorTempKelvin is the upper supported color temperature bound.
EffectList []string 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 { type TurnOnParams struct {
EntityID EntityID // EntityID is the target light entity.
BrightnessPct *uint32 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 ColorTempKelvin *uint32
RGBColor *RGBColor // RGBColor is nil when RGB color should be left unchanged.
Transition *uint32 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 { type RGBColor struct {
R, G, B uint8 R, G, B uint8
} }
// TurnOffParams collects optional light turn-off parameters.
type TurnOffParams struct { type TurnOffParams struct {
EntityID EntityID // EntityID is the target light entity.
EntityID EntityID
// Transition is nil when no explicit transition is requested.
Transition *uint32 Transition *uint32
} }

View File

@ -1,8 +1,13 @@
package domain package domain
// Switch represents a discovered switch entity.
type Switch struct { 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 FriendlyName string
State string // "on" | "off" | "unavailable" // State is the current raw switch state, usually on, off, or unavailable.
DeviceClass string // e.g. "switch" State string // "on" | "off" | "unavailable"
// DeviceClass identifies the switch semantic type when Home Assistant sets it.
DeviceClass string // e.g. "switch"
} }

View File

@ -6,17 +6,25 @@ import (
) )
type HAClient interface { type HAClient interface {
// GetState fetches the current state for one Home Assistant entity.
GetState(ctx context.Context, entityID string) (*HAState, error) 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) 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) CallService(ctx context.Context, domain, service string, payload map[string]any) ([]*HAState, error)
} }
// HAState mirrors the HA REST response. Internal type — not exposed outside // HAState mirrors the HA REST response. Internal type — not exposed outside
// the driven port layer. // the driven port layer.
type HAState struct { type HAState struct {
EntityID string // EntityID is the Home Assistant entity identifier.
State string EntityID string
Attributes map[string]any // 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 LastChanged time.Time
// LastUpdated is when any part of the state last updated in Home Assistant.
LastUpdated time.Time LastUpdated time.Time
} }

View File

@ -7,6 +7,8 @@ import (
) )
type EntityService interface { type EntityService interface {
// GetState returns the current state for one entity identifier.
GetState(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) 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) ListStates(ctx context.Context, ids []domain.EntityID, domainFilter string) ([]*domain.EntityState, error)
} }

View File

@ -7,9 +7,14 @@ import (
) )
type LightService interface { type LightService interface {
// TurnOn turns on a light and returns the resulting entity state.
TurnOn(ctx context.Context, p domain.TurnOnParams) (*domain.EntityState, error) 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) 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) 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) ListLights(ctx context.Context) ([]domain.Light, error)
// Refresh repopulates the light discovery cache from Home Assistant states.
Refresh(ctx context.Context) error Refresh(ctx context.Context) error
} }

View File

@ -7,6 +7,8 @@ import (
) )
type SwitchService interface { type SwitchService interface {
// ListSwitches returns cached switch metadata for discovery and UI use.
ListSwitches(ctx context.Context) ([]domain.Switch, error) ListSwitches(ctx context.Context) ([]domain.Switch, error)
// Refresh repopulates the switch discovery cache from Home Assistant states.
Refresh(ctx context.Context) error Refresh(ctx context.Context) error
} }

View File

@ -9,6 +9,7 @@ import (
type contextKey struct{} type contextKey struct{}
// New constructs a root logger for the configured format and level.
func New(format, level string) *slog.Logger { func New(format, level string) *slog.Logger {
var parsed slog.Level var parsed slog.Level
if err := parsed.UnmarshalText([]byte(level)); err != nil { 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)) 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 { func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, contextKey{}, l) 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 { func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil { if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil {
return l return l

View File

@ -20,6 +20,7 @@ import (
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config" "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) { func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) (shutdown func(context.Context) error, err error) {
if cfg.OTELEndpoint == "" { if cfg.OTELEndpoint == "" {
otel.SetTracerProvider(tracenoop.NewTracerProvider()) otel.SetTracerProvider(tracenoop.NewTracerProvider())
@ -69,6 +70,7 @@ func Setup(ctx context.Context, serviceName, version string, cfg *config.Config)
otel.SetMeterProvider(mp) otel.SetMeterProvider(mp)
return func(ctx context.Context) error { 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) shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
var shutdownErr error var shutdownErr error