diff --git a/README.md b/README.md index 2cbb644..eef3fea 100644 --- a/README.md +++ b/README.md @@ -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.