---
name: homeassistant-integration
description: "Connect Home Assistant to Hermes — token setup, toolset enablement, and smart home control via Hermes tools."
version: 1.2.0
author: ray
platforms: [linux]
created_by: "agent"
metadata:
  hermes:
    tags: [smart-home, homeassistant, integration, ha]
  requires:
    env_vars: ["HASS_TOKEN", "HASS_URL"]
    toolsets: ["homeassistant"]
    gateway_restart: true
---

# Home Assistant + Hermes Integration

Connect a Home Assistant instance to Hermes so the agent can list entities, read sensor states, discover services, and control devices (lights, switches, climate, media players, etc.) via the `homeassistant` toolset.

## Prerequisites

- A running Home Assistant instance (local network or remote)
- A **Long-Lived Access Token** from your HA profile page (click your username → Security → Long-Lived Access Tokens)
- The HA server accessible from the Hermes host

## Setup

### 1. Get a Long-Lived Access Token

In Home Assistant: **click your user profile** (bottom-left) → **Security** → **Long-Lived Access Tokens** → **Create Token**.

The token is a JWT that looks like:
```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOi...<rest of token>
```

You can decode it to verify:
```bash
python3 -c "
import base64, json
payload = base64.urlsafe_b64decode('PAYLOAD_SECTION' + '==')
data = json.loads(payload)
print(f'Issuer: {data[\"iss\"]}')
print(f'Expires: {data[\"exp\"]}')
"
```
HA long-lived access tokens typically expire in 10 years.

### 2. Set the Environment Variables

```bash
hermes config set HASS_URL http://YOUR_HA_HOST:8123
hermes config set HASS_TOKEN <your-long-lived-access-token>
```

`hermes config set` auto-detects secrets — the URL goes to `config.yaml`, the token goes to `.env`. The tool code reads **both** from environment variables, so if `HASS_URL` landed in config.yaml instead of `.env`, add it to `.env` as well:

```bash
echo 'HASS_URL=http://YOUR_HA_HOST:8123' >> ~/.hermes/.env
```

Verify they're in place:
```bash
grep HASS ~/.hermes/.env
```

### 3. Enable the Toolset

```bash
hermes tools enable homeassistant
```

Verify:
```bash
hermes tools list | grep homeassistant
# ✓ enabled  homeassistant  🏠 Home Assistant
```

### 4. Verify the Token Works

Test with a direct API call before restarting the gateway:

```bash
python3 -c "
import json, urllib.request, subprocess
token = subprocess.run(['grep', 'HASS_TOKEN', '$HOME/.hermes/.env'],
    capture_output=True, text=True).stdout.strip().split('=',1)[1]
req = urllib.request.Request('http://YOUR_HA_HOST:8123/api/',
    headers={'Authorization': f'Bearer {token}'})
data = json.loads(urllib.request.urlopen(req, timeout=10).read())
print(f'HA: {data.get(\"location_name\", \"unnamed\")}')
"
```

Also check entity count:
```bash
# Same pattern, hit /api/states instead and count by domain
```

If you get **HTTP 200**, the token is valid and the URL is reachable.

### 5. Restart the Gateway

Tool changes require a new session. Restart the gateway:

```bash
hermes gateway restart
```

After restart, the agent has access to four HA tools:

| Tool | Purpose |
|------|---------|
| `ha_list_entities` | List/filter entities by domain or area |
| `ha_get_state` | Get detailed state + attributes of one entity |
| `ha_list_services` | Discover available services (actions) per domain |
| `ha_call_service` | Control a device (turn_on, turn_off, set_temperature, etc.) |

## Security

The `homeassistant_tool.py` implementation blocks these high-risk service domains for safety:
`shell_command`, `command_line`, `python_script`, `pyscript`, `hassio`, `rest_command`

## Automation Patterns

### Cron-Based Condition Monitoring

Use Hermes cron jobs to poll an HA entity and act when a condition is met (e.g., turn off AC when temp drops to target). The cron job should self-terminate after acting.

**Recipe — trigger action on sensor threshold:**

```
cronjob(action='create',
  name='<descriptive name>',
  prompt='Check <entity_id> using ha_get_state. If <condition>, call ha_call_service to <action> and report success, then use cronjob to remove this monitoring job (job_id in context). If condition not met, report current state briefly and do nothing.',
  schedule='*/10 22-23,0-2 * * *',   # every 10 min from 10pm–2am (use CRON EXPRESSION, not "10m" — that's a one-shot)
  repeat=20,
  toolsets=['homeassistant', 'cronjob'])
```

Key details:
- **toolsets**: Must include both `homeassistant` and `cronjob` so the cron agent can check sensors, call services, and remove itself.
- **Schedule**: Use `*/N hour-range * * *` for time-windowed polling (e.g., `*/7 22-23,0-2 * * *` = every 7 minutes from 10 PM through 2 AM). Avoid starting monitoring before the user wants it — use the cron expression to delay first run.
- **CRITICAL — schedule format**: Simple time strings like `"10m"` or `"30m"` are parsed as **one-shot** ("once in 10 minutes"), NOT recurring. The cron will run exactly once and then stop. Always use cron expressions (`*/10 * * * *`) or `"every 10m"` for recurring jobs. A job that silently runs once and completes without acting is the #1 failure mode for HVAC monitoring.
- **Repeat limit**: Cap with `repeat` to prevent indefinite polling. With N-minute intervals, `repeat=20` covers ~N×20 minutes.
- **Prompt must include self-removal**: The job should call `cronjob(action='remove', job_id=...)` after acting so it doesn't keep polling indefinitely.
- **Schedule corrections**: Users often tweak poll intervals or start times after creation. Use `cronjob(action='update', job_id=..., schedule=...)` rather than deleting and recreating.

## Pitfalls

- **Home Assistant behind VPS reverse proxy:** When proxying HA through an external nginx (VPS + Tailscale), add the proxy's Tailscale IP to `trusted_proxies` in `configuration.yaml` or HA returns 400:
  ```yaml
  http:
    use_x_forwarded_for: true
    trusted_proxies:
      - 127.0.0.1
      - 100.86.68.23   # VPS Tailscale IP
  ```
  Restart: `docker restart homeassistant`. See `docker-service-deployment` skill, `references/vps-migration.md` for the full VPS setup pattern.
- **CRITICAL — Cron schedule format:** Bare duration strings like `"10m"` or `"30m"` are ONE-SHOT ("once in X minutes"). The job runs exactly once and silently completes — the #1 failure mode for HVAC monitoring. Always use **cron expressions**: `"*/10 * * * *"` (every 10 minutes), `"*/7 22-23,0-2 * * *"` (time-windowed). Alternative: `"every 10m"` shorthand. Verify with `cronjob(action='list')` after creation — `"once in Nm"` means it won't repeat.
- **Climate vs sensor entities:** Climate entities (`climate.upstairs`) are the thermostat — call `ha_call_service` to control them. Sensor entities (`sensor.upstairs_temperature`) are read-only readings. For threshold monitoring, check the sensor for the reading but control the climate entity.
- **HASS_URL must be in .env**: The tool reads `os.getenv("HASS_URL")`, not `config.yaml`. Add it to `.env` explicitly.
- **Docs 404**: The Hermes docs page for Home Assistant integration returns 404. Canonical reference: `tools/homeassistant_tool.py` and `gateway/config.py`.
- **Subprocesses don't inherit .env**: When testing with `python3 -c`, read secrets via `subprocess.run(['grep', ...])` instead of `os.getenv()`.
- **Gateway restart required**: Toolset changes need `hermes gateway restart`.
- **Internal vs proxy URL**: The tool needs the direct API URL (e.g., `http://192.168.50.98:8123`), not the external proxy port.
- **Entity registry may 404**: Use `/api/config/config_entries/entry` instead to discover integrations and device metadata.

### Two-Phase Monitoring (Wide → Narrow Polling)

When the target is far away, poll infrequently to save resources. When it nears the threshold, switch to tight polling automatically.

**How it works:** The cron job starts with a wide interval (e.g., every 30 min). When the sensor value enters a "close" zone, the job updates its own schedule to a tight interval (e.g., every 7 min). When the final threshold is hit, it acts and removes itself.

**Recipe:**

```
# Phase 1 — wide polling (every 30 min on the half-hour, 10pm–2am)
cronjob(action='create',
  name='Turn off AC when temp hits 78°F',
  prompt='Check climate.upstairs with ha_get_state. '
    'If temp > 79°F: report briefly, do nothing (stay in wide-poll mode). '
    'If temp ≤ 79°F and > 78°F: UPGRADE this job to tight polling — '
    'cronjob(action=update, job_id=THIS_JOB_ID, schedule="*/7 22-23,0-2 * * *"). '\
    'Report "now monitoring closely." '
    'If temp ≤ 78°F: ha_call_service(domain=climate, service=turn_off, '
    'entity_id=climate.upstairs), report success, remove this job.',
  schedule='0,30 22-23,0-2 * * *',
  repeat=20,
  toolsets=['homeassistant','cronjob'])
```

Once upgraded to Phase 2, the job's prompt should know it's in tight mode: check temp, act if ≤ target, otherwise report and wait. The prompt must handle both modes so it works correctly after a self-upgrade.

Key points:
- **Phase 1 prompt** must include the self-upgrade logic (wide → tight) AND the final action logic (act + self-remove).
- **Phase 2 prompt** (after upgrade) only needs: check, act-if-condition-met, otherwise report.
- **Use `repeat`** to cap total runs so the job doesn't poll forever if the condition never triggers.
- Users often specify multiple constraints (interval, start time, threshold-based frequency). Ask clarifying questions if the intent is ambiguous, and prefer the two-phase pattern when the target is expected to drift slowly.

## IoT Device Onboarding

For finding, identifying, and adding new WiFi IoT devices to the network and Home Assistant — WiFi setup, IP discovery, MAC vendor lookup, LAN mode enablement, and HA integration — see `references/iot-device-onboarding.md`.

## Identifying Devices & Integrations

### Thermostat Model / Integration Discovery

The entity states API (`/api/states`) doesn't expose manufacturer or model info. To find what integration provides a climate entity and what hardware it's connected to, query the HA config entries API:

```python
import json, urllib.request

# List all config entries (integrations)
req = urllib.request.Request(
    'http://HA_HOST:8123/api/config/config_entries/entry',
    headers={'Authorization': f'Bearer {token}'})
entries = json.loads(urllib.request.urlopen(req).read())

for entry in entries:
    if entry.get('domain') == 'honeywell':  # or 'ecobee', 'nest', etc.
        print(f"Title: {entry['title']}, State: {entry['state']}")
```

The `domain` field reveals the integration (e.g., `honeywell`, `ecobee`, `nest`). The entity registry endpoints (`/api/config/entity_registry/*`) may 404 depending on HA version or reverse-proxy setup — the config entries API is a reliable fallback.
