Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,95 @@ Other safety behavior worth knowing:
`intensity` to `low`/`medium`/`high`, and `action` to `start`/`stop`;
everything else is rejected with `400`.

## Home Assistant (MQTT)

Optional. Off by default. When enabled, the controller connects to your MQTT
broker, self-registers in Home Assistant via **MQTT Discovery**, publishes its
state every control tick, and accepts fan overrides and workload hints over MQTT
— full parity with the HTTP API.

Enable it in `config.yaml`:

```yaml
mqtt:
enabled: true
broker: "tcp://192.168.1.10:1883"
username: "homeassistant"
password: "your-broker-password"
client_id: "only-fan-controller"
base_topic: "only-fan-controller"
discovery_prefix: "homeassistant"
```

Or via environment variables: `MQTT_ENABLED=true`, `MQTT_BROKER=tcp://…`,
`MQTT_USERNAME=…`, `MQTT_PASSWORD=…`.

### What appears in Home Assistant

All entities are grouped under one device, **Only Fan Controller**:

| Entity | Type | Notes |
|--------|------|-------|
| CPU Temperature | sensor | °C |
| GPU Temperature (Max) | sensor | °C; hottest card across all GPUs (drives fan logic) |
| GPU _N_ (_model_) Temperature / Utilization / Power | sensor | one set per detected GPU (°C / % / W) |
| Fan Speed, Target Fan Speed | sensor | % |
| Thermal Zone, Failsafe Reason | sensor | text |
| Failsafe Active, Restore Pending, Last Fan Write Failed | binary_sensor | `problem` class |
| Override Fan Speed | number | slider bound to `min_speed`/`max_speed`; sends a 1-hour override |
| Clear Fan Override | button | clears any active override |

**Per-GPU sensors are dynamic in card count.** On a two-GPU box you get two sets
of Temperature/Utilization/Power sensors, on a three-GPU box three, and so on.
Each card is announced as soon as the controller's first GPU read completes (a
few seconds after start — MQTT connects before the sensors are read, so the
per-card entities appear on the first control tick, not instantly). A GPU added
later is picked up automatically on the next tick. If the card count *shrinks*,
the stale per-card entities linger in Home Assistant as *unavailable* until you
delete them (the controller does not remove discovery entries).

The device is marked **unavailable** the instant the process dies — the broker
publishes the Last Will (`offline`) on ungraceful disconnect, giving you free
external monitoring for the fail-safe scenarios.

`broker` is forgiving about format: a bare host, `host:port`, or even a browser
URL pasted from Home Assistant (`http://ha.local:8123`) is normalized to
`tcp://host:port` at startup (the interpretation is logged). If the broker turns
out to be unreachable — e.g. you pointed it at HA's web UI port `8123` instead of
the MQTT broker's `1883` — the log calls that out explicitly instead of silently
timing out.

### Topics

- `only-fan-controller/availability` — retained `online`/`offline`.
- `only-fan-controller/state` — retained JSON, one document per control tick.
- `only-fan-controller/cmd/override` — `{"speed": 60, "duration_seconds": 3600, "reason": "..."}`
- `only-fan-controller/cmd/override/clear` — any payload clears the override.
- `only-fan-controller/cmd/hint` — `{"type": "transcode", "action": "start|stop", "intensity": "high", "source": "plex", "duration_estimate": 120}`

Commands go through the exact same safety clamps and validation as the HTTP API:
speed is clamped to `min_speed`/`max_speed`, override duration is capped at 24h,
the critical-temperature ramp overrides everything, and hint fields are charset/
length checked. A malformed command is logged and dropped, never applied.

Command topics must be published **without** the retain flag — a retained
command would be replayed by the broker on every reconnect and restart. The
bridge deliberately drops retained messages on the command topics (logging the
drop once per topic) so a stray `mosquitto_pub -r` or a misconfigured automation
cannot silently re-fire the fans forever.

Hints have no dedicated HA entity (a source/type/intensity tuple doesn't map to
one) — drive `cmd/hint` from an HA automation for the full-parity escape hatch.

### Security

**MQTT command authorization is your broker's authentication.** The HTTP bearer
token is HTTP-only and does not apply here — anyone who can publish to the broker
can command the fans, so protect it with broker credentials/ACLs. The blast
radius is still bounded by the controller-level clamps and the critical-temp
override. **There is no TLS in v1**; keep the broker on a trusted LAN or in front
of a TLS-terminating proxy.

## API Endpoints

### GET /api/status
Expand Down Expand Up @@ -328,6 +417,10 @@ All config options can be overridden via environment variables:
| `CHECK_INTERVAL` | Seconds between checks | 10 |
| `API_PORT` | API/Dashboard port | 8086 |
| `API_TOKEN` | Bearer token for mutating endpoints | - (loopback-only) |
| `MQTT_ENABLED` | Enable Home Assistant MQTT bridge | false |
| `MQTT_BROKER` | MQTT broker URL (`tcp://host:port`) | - (required when enabled) |
| `MQTT_USERNAME` | MQTT broker username | - |
| `MQTT_PASSWORD` | MQTT broker password | - |

## Unraid Installation

Expand Down
46 changes: 46 additions & 0 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/sethpjohnson/only-fan-controller/internal/config"
"github.com/sethpjohnson/only-fan-controller/internal/controller"
"github.com/sethpjohnson/only-fan-controller/internal/monitor"
"github.com/sethpjohnson/only-fan-controller/internal/mqtt"
"github.com/sethpjohnson/only-fan-controller/internal/storage"
)

Expand Down Expand Up @@ -93,6 +94,20 @@ func applyEnvOverrides(cfg *config.Config) {
if v := os.Getenv("API_TOKEN"); v != "" {
cfg.API.Token = v
}

// MQTT / Home Assistant bridge
if v := os.Getenv("MQTT_ENABLED"); v != "" {
cfg.MQTT.Enabled = strings.ToLower(v) == "true" || v == "1"
}
if v := os.Getenv("MQTT_BROKER"); v != "" {
cfg.MQTT.Broker = v
}
if v := os.Getenv("MQTT_USERNAME"); v != "" {
cfg.MQTT.Username = v
}
if v := os.Getenv("MQTT_PASSWORD"); v != "" {
cfg.MQTT.Password = v
}
}

func main() {
Expand Down Expand Up @@ -134,6 +149,11 @@ func restoreOnce(restore func() error) func() error {
// this wait starts, so a hung Cleanup query must not stall process exit.
const cleanupShutdownTimeout = 5 * time.Second

// mqttShutdownTimeout bounds how long shutdown waits for the MQTT bridge to
// publish offline + disconnect. Thermal safety (restore()) always runs before
// this, so a wedged broker must never stall process exit.
const mqttShutdownTimeout = 3 * time.Second

// runHistoryCleanup prunes readings older than retention on a fixed daily
// interval until stopCh is closed. The initial cleanup (so a fresh start
// doesn't wait a full day before its first prune) is run by the caller before
Expand Down Expand Up @@ -273,6 +293,17 @@ func run() int {
log.Printf("API server listening on %s:%d", cfg.API.Host, cfg.API.Port)
log.Printf("Dashboard: http://localhost:%d/dashboard/", cfg.API.Port)

// Optional MQTT / Home Assistant bridge. Off unless mqtt.enabled. It talks to
// the controller only through the exported Consumer methods (the same ones the
// HTTP handlers use), so a hung/unreachable broker can never stall fan control.
// fanCtrl is a *controller.FanController in both real and demo mode, so the
// bridge (and thus HA) works in demo mode too.
var mqttBridge *mqtt.Bridge
if cfg.MQTT.Enabled {
mqttBridge = mqtt.New(cfg, fanCtrl, mqtt.NewPahoClient)
mqttBridge.Start()
}

// Wait for a shutdown signal or a fatal error.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
Expand All @@ -294,6 +325,21 @@ func run() int {
log.Printf("Warning: Failed to restore auto fan mode: %v", err)
}

// Tear down the MQTT bridge AFTER the BMC hand-back choke point, bounded so a
// wedged broker cannot delay exit. Mirrors the history-cleanup shutdown pattern.
if mqttBridge != nil {
stopped := make(chan struct{})
go func() {
mqttBridge.Stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(mqttShutdownTimeout):
log.Printf("Warning: MQTT bridge did not stop within %s; abandoning it", mqttShutdownTimeout)
}
}

// Stop the history cleanup goroutine before the deferred store.Close() runs.
// Bounded: if a Cleanup query happens to be in flight, don't let it stall
// exit indefinitely (thermal safety already happened above, in restore()).
Expand Down
22 changes: 22 additions & 0 deletions cmd/controller/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,30 @@ import (
"path/filepath"
"testing"
"time"

"github.com/sethpjohnson/only-fan-controller/internal/config"
)

func TestApplyEnvOverridesMQTT(t *testing.T) {
t.Setenv("MQTT_ENABLED", "true")
t.Setenv("MQTT_BROKER", "tcp://10.0.0.9:1883")
t.Setenv("MQTT_USERNAME", "ha")
t.Setenv("MQTT_PASSWORD", "brokerpw")

cfg := config.Default()
applyEnvOverrides(cfg)

if !cfg.MQTT.Enabled {
t.Fatal("MQTT_ENABLED=true should enable mqtt")
}
if cfg.MQTT.Broker != "tcp://10.0.0.9:1883" {
t.Fatalf("broker override not applied: %q", cfg.MQTT.Broker)
}
if cfg.MQTT.Username != "ha" || cfg.MQTT.Password != "brokerpw" {
t.Fatalf("credentials override not applied: %q / %q", cfg.MQTT.Username, cfg.MQTT.Password)
}
}

func TestResolveConfigMissingFileFallsBackToDefaults(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
cfg, err := resolveConfig(missing)
Expand Down
14 changes: 14 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ storage:
# at startup and then once a day. Must be > 0.
retention_days: 30

# Optional Home Assistant integration over MQTT. Off by default: when disabled
# there is zero MQTT activity and no behavior change. When enabled, `broker` is
# REQUIRED (the service refuses to start otherwise). There is NO TLS support —
# anyone who can publish to the broker can command the fans, so protect it with
# broker credentials/ACLs. Publish cadence reuses monitoring.interval above.
mqtt:
enabled: false # off by default
broker: "tcp://192.168.1.10:1883" # required when enabled (tcp://host:port)
username: "" # optional broker username
password: "" # optional broker password; never exposed via /api/config
client_id: "only-fan-controller" # MQTT client id and HA device identifier
base_topic: "only-fan-controller" # root for state/command/availability topics
discovery_prefix: "homeassistant" # HA MQTT Discovery root

# Logs go to stderr; there is no file-logging config. Docker/systemd/your
# process supervisor is responsible for capturing and rotating them (e.g.
# `docker logs`, or the json-file/local log driver's rotation options).
Loading