feat: update README.md to enhance project overview and clarify service architecture
All checks were successful
CI / test (push) Successful in 4s
CI / build-ha-gateway (push) Successful in 1m2s
CI / build-discord-bot (push) Successful in 45s

This commit is contained in:
Nik Afiq 2026-04-07 08:13:05 +09:00
parent 7a0b0f1540
commit d6236d70b9

416
README.md
View File

@ -1,59 +1,276 @@
# home-service
This workspace contains a Home Assistant gRPC gateway and the protobuf contract
it serves.
This repo is a small Home Assistant control platform built around gRPC.
The current implementation is centered on `ha-gateway`, a Go service that:
There are three important pieces:
- exposes Home Assistant operations over gRPC
- translates gRPC requests into Home Assistant REST API calls
- emits OpenTelemetry traces and metrics when configured
- keeps protobuf definitions and generated Go stubs in-repo
- `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`
`EntityService` is implemented, `LightService` supports both discovery and
control, `SwitchService` supports discovery, and `EventService` is still
scaffolded.
If you are new to gRPC, the most important idea is:
## Workspace Layout
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
## What Each Folder Does
```text
.
├── proto/ # Source protobuf definitions
├── gen/ # Generated Go protobuf/grpc code (committed)
├── ha-gateway/ # Go gRPC server
├── buf.yaml # Buf module config
├── buf.gen.yaml # Buf codegen config
└── go.work # Go workspace linking gen + ha-gateway
├── 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
```
## Architecture
## Why `gen/` Exists
`ha-gateway` follows a ports-and-adapters structure:
`gen/` is the bridge between your `.proto` files and your Go services.
- `internal/core/domain`: pure domain types
- `internal/core/ports/driving`: interfaces exposed to primary adapters
- `internal/core/ports/driven`: interfaces the app layer depends on
- `internal/app`: application logic for entity, light, and switch operations
- `internal/adapters/primary/grpc`: gRPC handlers and proto/domain mapping
- `internal/adapters/secondary/ha`: Home Assistant REST client
- `internal/telemetry`: OpenTelemetry setup
The `.proto` files in `proto/ha/v1/` are the source of truth. They define:
The runtime flow is:
- service names like `LightService` and `SwitchService`
- RPC methods like `ListLights`, `TurnOn`, and `ListSwitches`
- message schemas like `TurnOnRequest`, `LightEntity`, and `SwitchEntity`
1. A gRPC client calls `EntityService`, `LightService`, or `SwitchService`.
2. The gRPC adapter maps protobuf messages into domain parameters.
3. The app layer orchestrates the use case.
4. The HA adapter calls Home Assistant's REST API.
5. The response is mapped back into the protobuf response.
Those `.proto` files are not directly used by Go at runtime. They are compiled
into Go source files in `gen/ha/v1/`, such as:
For discovery-style RPCs, the light and switch apps cache filtered entity lists
from Home Assistant state snapshots. The cache is primed at startup on a
best-effort basis and lazily refreshed on the first request if startup
discovery fails.
- `light.pb.go`
- `light_grpc.pb.go`
- `switch.pb.go`
- `switch_grpc.pb.go`
## Services
That generated code gives you:
### Implemented
- Go structs for protobuf messages
- gRPC client interfaces for callers
- gRPC server interfaces and registration helpers for servers
In this repo:
- `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(...)`
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`
@ -66,45 +283,56 @@ discovery fails.
- `ha.v1.SwitchService`
- `ListSwitches`
### Partially Stubbed
### Partially Stubbed in `ha-gateway`
- `ha.v1.SwitchService`
- `TurnOn`
- `TurnOff`
- `Toggle`
### Stubbed
### Stubbed in `ha-gateway`
- `ha.v1.EventService`
The event path is planned around Home Assistant WebSocket subscriptions, but the
WebSocket adapter and fan-out broker are not implemented yet.
## 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` reads configuration from environment variables. A sample file lives
at [ha-gateway/.env.example](/Users/nik-macbookair/repo/home-service/ha-gateway/.env.example).
### `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; empty disables telemetry |
| `OTEL_ENDPOINT` | no | empty | OTLP gRPC endpoint |
Notes:
### `discord-bot`
- startup fails if `HA_TOKEN` is missing
- `HA_BASE_URL` is not validated on load, but the gateway cannot reach Home
Assistant without it
- if `OTEL_ENDPOINT` is empty, the service installs no-op telemetry providers
Sample env file:
[discord-bot/.env.example](https://gitea.nik4nao.com/nik/home-services/src/branch/main/discord-bot/.env.example)
## Prerequisites
- Go `1.26`
- `buf` for protobuf generation
- a reachable Home Assistant instance
- a valid Home Assistant long-lived access token
| Variable | Required | Notes |
| --- | --- | --- |
| `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 |
## Generate Protobuf Code
@ -114,70 +342,57 @@ Run from the repo root:
buf generate
```
Generated files are written to `gen/ha/v1`.
That regenerates `gen/ha/v1/*.pb.go` from `proto/ha/v1/*.proto`.
## Build
Sync the workspace, then build the gateway:
Build each module from its own directory:
```bash
go work sync
cd ha-gateway
go build ./...
cd gen && go build ./...
cd ../ha-gateway && go build ./...
cd ../discord-bot && go build ./...
```
## Run Locally
Create a local env file:
### Start `ha-gateway`
```bash
cp ha-gateway/.env.example ha-gateway/.env
```
Fill in `HA_TOKEN` and `HA_BASE_URL`, then start the server:
```bash
cd ha-gateway
go run ./cmd/gateway
```
The gateway listens on `:50051` by default.
### Start `discord-bot`
## Smoke Test With grpcurl
```bash
cp discord-bot/.env.example discord-bot/.env
cd discord-bot
go run ./cmd/bot
```
The server registers gRPC reflection, so `grpcurl` can inspect it directly:
## 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 against a locally running gateway:
Examples:
```bash
# List states filtered by domain
grpcurl -plaintext -d '{"domain":"light"}' \
localhost:50051 ha.v1.EntityService/ListStates
# List discovered lights
grpcurl -plaintext -d '{}' \
localhost:50051 ha.v1.LightService/ListLights
# List discovered switches
grpcurl -plaintext -d '{}' \
localhost:50051 ha.v1.SwitchService/ListSwitches
# Get one entity
grpcurl -plaintext -d '{"entity_id":"light.living_room"}' \
localhost:50051 ha.v1.EntityService/GetState
# Turn on a light at 80% brightness
grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' \
localhost:50051 ha.v1.LightService/TurnOn
# Toggle a light
grpcurl -plaintext -d '{"entity_id":"light.living_room"}' \
localhost:50051 ha.v1.LightService/Toggle
grpcurl -plaintext -d '{}' \
localhost:50051 ha.v1.SwitchService/ListSwitches
```
## Docker
@ -186,31 +401,22 @@ Build from the repo root:
```bash
docker build -f ha-gateway/Dockerfile -t ha-gateway:dev .
```
Run it with the same env file:
```bash
docker run --env-file ha-gateway/.env -p 50051:50051 ha-gateway:dev
docker build -f discord-bot/Dockerfile -t discord-bot:dev .
```
## Telemetry
When `OTEL_ENDPOINT` is set, the gateway exports:
Both runtime services support OTLP/gRPC telemetry:
- traces via OTLP/gRPC
- metrics via OTLP/gRPC
- `ha-gateway` uses service name `ha-gateway`
- `discord-bot` uses service name `discord-bot`
The service name is `ha-gateway`. When `OTEL_ENDPOINT` is unset, telemetry is
disabled for local development.
If `OTEL_ENDPOINT` is empty, telemetry is disabled.
## Current Limitations
- no authentication/authorization on inbound gRPC requests yet
- `SwitchService` control RPCs (`TurnOn`, `TurnOff`, `Toggle`) still return
`Unimplemented`
- `EventService` is not implemented
- 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
The auth note in [ha-gateway/cmd/gateway/main.go](/Users/nik-macbookair/repo/home-service/ha-gateway/cmd/gateway/main.go) explicitly calls out API-key and mTLS as future options before exposing the gateway outside a trusted network.