Enhance Discord bot and HA gateway with improved structure and documentation
- 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:
parent
b5592a1705
commit
6ea4e84949
544
README.md
544
README.md
@ -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
|
||||
|
||||
@ -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]"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user