home-services/README.md
Nik Afiq d6236d70b9
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
feat: update README.md to enhance project overview and clarify service architecture
2026-04-07 08:13:05 +09:00

423 lines
12 KiB
Markdown

# home-service
This repo is a small Home Assistant control platform built around gRPC.
There are three important pieces:
- `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`
If you are new to gRPC, the most important idea is:
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 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
```
## Why `gen/` Exists
`gen/` is the bridge between your `.proto` files and your Go services.
The `.proto` files in `proto/ha/v1/` are the source of truth. They define:
- service names like `LightService` and `SwitchService`
- 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
into Go source files in `gen/ha/v1/`, such as:
- `light.pb.go`
- `light_grpc.pb.go`
- `switch.pb.go`
- `switch_grpc.pb.go`
That generated code gives you:
- 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`
- `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 |
| `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
Run from the repo root:
```bash
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
cp ha-gateway/.env.example ha-gateway/.env
cd ha-gateway
go run ./cmd/gateway
```
### Start `discord-bot`
```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
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:
```bash
docker build -f ha-gateway/Dockerfile -t ha-gateway:dev .
docker build -f discord-bot/Dockerfile -t discord-bot:dev .
```
## Telemetry
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