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
- `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

View File

@ -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]"

View File

@ -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()

View File

@ -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
}

View File

@ -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")

View File

@ -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":

View File

@ -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

View File

@ -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 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 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 is the Home Assistant entity identifier.
EntityID string
// FriendlyName is the user-facing name from Home Assistant.
FriendlyName string
// State is the raw switch state 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{}
// 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

View File

@ -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

View File

@ -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]"

View File

@ -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 {

View File

@ -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

View File

@ -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(),

View File

@ -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 {

View File

@ -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),

View File

@ -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)
}

View File

@ -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 {

View File

@ -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.

View File

@ -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 {

View File

@ -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),

View File

@ -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),

View File

@ -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

View File

@ -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 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")

View File

@ -1,38 +1,62 @@
package domain
// ColorMode describes a Home Assistant light color capability.
type ColorMode string
const (
// 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 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 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 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 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 is the target light entity.
EntityID EntityID
// Transition is nil when no explicit transition is requested.
Transition *uint32
}

View File

@ -1,8 +1,13 @@
package domain
// Switch represents a discovered switch entity.
type Switch struct {
// 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 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"
}

View File

@ -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 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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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