HATS

Home Assistant + NATS = HATS

A bridge that streams Home Assistant events into NATS subjects and exposes a lightweight caching proxy for Home Assistant’s REST API.

Alpha software. I have been running this at home for over a year, but breaking changes can occur at any time.

How it works

HATS connects to Home Assistant via websocket and republishes state-change events onto NATS subjects in real time. Subscriber services connect to NATS and react to events — no polling, no direct Home Assistant dependency in your subscriber code. HATS also runs an HTTP API that caches entity state in a JetStream KV store, so state reads inside event handlers don’t hit Home Assistant on every event.

Home Assistant ──ws──▶ HATS ──pub──▶ NATS
                         │
                         └──────────▶ HTTP API (cached state, timers, schedules, commands)
                                           ▲
                              Subscriber ──┘

Quick start

1. Run HATS alongside NATS (see example/compose.yaml for a fuller version):

services:
  nats:
    image: nats:2
    command: -js  # enable JetStream
    ports:
      - "4222:4222"

  hats:
    image: codeberg.org/jhot/hats:latest
    ports:
      - "8888:8888"
    environment:
      - HASS_HOST=192.168.1.100      # your Home Assistant IP
      - HASS_TOKEN=your-ha-token     # long-lived access token from HA profile
      - NATS_HOST=nats
      - HATS_TOKEN=changeme          # bearer token for the HATS API
    depends_on:
      - nats

2. Write a subscriber:

package main

import (
    "log/slog"
    "os"
    "os/signal"
    "syscall"

    "codeberg.org/jhot/hats/pkg/client"
    "codeberg.org/jhot/hats/pkg/config"
    "codeberg.org/jhot/hats/pkg/homeassistant"
    "codeberg.org/jhot/hats/pkg/nats"
)

func main() {
    cfg, _ := config.New()
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    hatsClient := client.NewHatsClient(cfg.GetHatsBaseUrl(), cfg.HatsToken)

    natsClient := nats.New(
        nats.WithoutJetstream,
        nats.WithHostName(cfg.NatsHost),
        nats.WithPort(cfg.NatsPort),
        nats.WithClientName(cfg.NatsClientName),
        nats.WithLogger(logger),
    )
    natsClient.Connect()
    defer natsClient.Close()

    // React when the sun rises
    natsClient.AddStateSub(nats.StateTopicType, "sun.sun", func(state homeassistant.StateData) error {
        if state.State == "above_horizon" {
            if motionOn, _ := hatsClient.GetStateBool("binary_sensor.living_room_motion"); !motionOn {
                return hatsClient.CallService("light.living_room", homeassistant.Services.TurnOff)
            }
        }
        return nil
    })

    sigch := make(chan os.Signal, 1)
    signal.Notify(sigch, syscall.SIGINT, syscall.SIGTERM)
    <-sigch
}

See the subscriber guide for a full walkthrough.

NATS topics

Subject Fires when Payload
homeassistant.states.{domain}.{entity}.{state} Entity state changes StateData
homeassistant.attributes.{domain}.{entity}.{state} Attributes change, state unchanged StateData
homeassistant.zha.{device_ieee} ZHA device event ZHA event data
homeassistant.zwave-scene.{device_id} Z-Wave JS scene event ZwaveJS value notification
homeassistant.nfc.{tag_id} NFC tag scanned NFC tag data
homeassistant.timers.{name}.finished HATS timer expired "finished"
schedules.{name} HATS cron schedule fired "finished"
command.{name} HTTP POST to /api/command/{name} Raw POST body bytes

See docs/nats-topics.md for full payload shapes and NATS CLI examples.

Configuration

Variable Default Description
HASS_HOST 127.0.0.1 Home Assistant hostname or IP
HASS_TOKEN (required) Long-lived access token from HA profile
NATS_HOST 127.0.0.1 NATS server hostname
NATS_PORT 4222 NATS server port
HATS_TOKEN Bearer token for the HATS API (optional)
LOG_LEVEL INFO DEBUG, INFO, WARN, or ERROR

See docs/getting-started.md for the full variable reference.

Features

Further reading