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

.
├── 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:

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 and registers a handler from handler.go.

When the user runs:

/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, 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, 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, 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, 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.

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, 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:

{
  "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, 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:

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

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

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:

buf generate

That regenerates gen/ha/v1/*.pb.go from proto/ha/v1/*.proto.

Build

Build each module from its own directory:

cd gen && go build ./...
cd ../ha-gateway && go build ./...
cd ../discord-bot && go build ./...

Run Locally

Start ha-gateway

cp ha-gateway/.env.example ha-gateway/.env
cd ha-gateway
go run ./cmd/gateway

Start discord-bot

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:

grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe ha.v1.LightService

Examples:

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:

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
Description
No description provided
Readme 326 KiB
Languages
Go 99.2%
Dockerfile 0.8%