initial commit
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Brother MFC → Paperless-ngx Scan Pipeline
|
||||
|
||||
Network scanner → Paperless consume folder, no drivers, no printer web admin password required.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Brother MFC with network (WiFi or Ethernet) on same LAN as server
|
||||
- `sane-airscan` installed (`sudo apt install sane-airscan sane-utils`)
|
||||
- Paperless-ngx running with a consume folder mounted
|
||||
|
||||
## Setup (once)
|
||||
|
||||
### 1. Verify scanner discovery
|
||||
|
||||
```bash
|
||||
scanimage -L
|
||||
# Should show:
|
||||
# device `airscan:e0:Brother MFC-J5855DW' is a eSCL Brother MFC-J5855DW ip=192.168.50.219
|
||||
```
|
||||
|
||||
No Brother drivers needed — `sane-airscan` uses the driverless eSCL/AirScan protocol.
|
||||
|
||||
### 2. Ensure write access to consume folder
|
||||
|
||||
```bash
|
||||
sudo setfacl -m u:$USER:rwx /path/to/paperless/consume
|
||||
```
|
||||
|
||||
### 3. Install scan script at `/usr/local/bin/scan-to-paperless`
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scan-to-paperless — scan from Brother MFC → Paperless consume folder
|
||||
# Usage: scan-to-paperless [name] [--flatbed] [--gray] [--150dpi]
|
||||
|
||||
set -e
|
||||
|
||||
DEVICE="airscan:e0:Brother MFC-J5855DW"
|
||||
CONSUME="/mnt/seagate8tb/paperless/consume"
|
||||
MODE="Color"
|
||||
RES="300"
|
||||
SOURCE="ADF"
|
||||
|
||||
NAME=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--flatbed) SOURCE="Flatbed" ;;
|
||||
--gray) MODE="Gray" ;;
|
||||
--150dpi) RES="150" ;;
|
||||
*) NAME="${1}_" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
SCAN_OPTS="--mode $MODE --resolution $RES"
|
||||
if [ "$SOURCE" = "ADF" ]; then
|
||||
SCAN_OPTS="$SCAN_OPTS --source ADF --batch-count=1"
|
||||
fi
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
FILENAME="${NAME}scan_${TIMESTAMP}.pdf"
|
||||
OUTFILE="$CONSUME/$FILENAME"
|
||||
|
||||
echo "📄 Scanning ($SOURCE, $MODE, ${RES}dpi) → $FILENAME"
|
||||
scanimage --device "$DEVICE" $SCAN_OPTS --format pdf -o "$OUTFILE"
|
||||
|
||||
SIZE=$(du -h "$OUTFILE" | cut -f1)
|
||||
echo "✅ Done — $SIZE saved to Paperless"
|
||||
```
|
||||
|
||||
## Daily usage
|
||||
|
||||
```bash
|
||||
scan-to-paperless # ADF, color 300dpi
|
||||
scan-to-paperless invoice # names it invoice_scan_20260614_172310.pdf
|
||||
scan-to-paperless --flatbed # use flatbed (photos, fragile docs)
|
||||
scan-to-paperless --gray --150dpi # B&W, low res (small file)
|
||||
```
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
scan-to-paperless
|
||||
→ Brother scans via eSCL (AirScan over HTTP)
|
||||
→ PDF saved to /mnt/seagate8tb/paperless/consume/
|
||||
→ Paperless auto-ingests, OCRs, classifies, tags
|
||||
```
|
||||
|
||||
## Alternative approaches that failed
|
||||
|
||||
- **Scan-to-FTP via Brother web interface**: Requires printer admin password. Defaults (`initpass`, `access`, `brother`) don't work on modern Brother MFCs (unique per-device password on sticker).
|
||||
- **Scan-to-SMB via Brother**: Also requires web admin to create the profile. Samba share was set up but could not be configured on the printer side without the web password.
|
||||
- **Brother brscan4 driver**: Proprietary `.deb` behind an EULA wall that requires browser cookie; `sane-airscan` driverless approach is simpler and works immediately.
|
||||
@@ -0,0 +1,92 @@
|
||||
# IoT Device Onboarding to Home Assistant
|
||||
|
||||
Pattern for adding new WiFi IoT devices (smart plugs, switches, sensors) to the local network and Home Assistant.
|
||||
|
||||
## Phase 1: Get Device on WiFi
|
||||
|
||||
### Stock Firmware (eWeLink / Tuya)
|
||||
1. Device broadcasts a setup AP when first powered or reset (e.g., `ITEAD-XXXX` or `SmartLife-XXXX`)
|
||||
2. Connect phone to that AP, use the **eWeLink** or **Smart Life** app to configure WiFi
|
||||
3. After WiFi config, the AP disappears and device joins the home network
|
||||
|
||||
### Tasmota / ESPHome (already flashed)
|
||||
1. Device broadcasts its own AP (e.g., `tasmota-XXXX-XXXX` or `esphome-XXXX`)
|
||||
2. Connect phone to the AP, browse to `http://192.168.4.1` to configure WiFi
|
||||
3. After save, device reboots onto the home network
|
||||
|
||||
## Phase 2: Find the IP Address
|
||||
|
||||
Devices often get DHCP-assigned IPs that aren't immediately obvious.
|
||||
|
||||
**Method A — Router DHCP table:**
|
||||
Browse to router admin (ASUS: `http://192.168.50.1`), check Client List for the new device.
|
||||
|
||||
**Method B — App device info:**
|
||||
In eWeLink app: tap device → ⚙️ Settings → Network Info / Device IP.
|
||||
In Tasmota web UI: Information → Network.
|
||||
|
||||
**Method C — ARP scan for new IPs:**
|
||||
```python
|
||||
# Compare current ARP table against known IPs to find new devices
|
||||
import subprocess
|
||||
arp_output = subprocess.run(['cat', '/proc/net/arp'], capture_output=True, text=True).stdout
|
||||
new_ips = [line.split()[0] for line in arp_output.split('\n')[1:] if line.strip()
|
||||
and line.split()[0] not in known_ips]
|
||||
```
|
||||
|
||||
**Method D — MAC vendor lookup:**
|
||||
Identify unknown devices by MAC OUI:
|
||||
```bash
|
||||
curl -s "https://api.macvendors.com/XX:XX:XX" # first 3 octets only
|
||||
# Sonoff/ITEAD common prefixes: 68:C6:3A, EC:FA:BC, C8:2B:96, DC:4F:22, 84:F3:EB
|
||||
```
|
||||
|
||||
**Method E — Port scan for IoT signatures:**
|
||||
```bash
|
||||
# Sonoff LAN API port (Tasmota HTTP on :80, eWeLink LAN on :8081)
|
||||
curl -s -m2 "http://DEVICE_IP:8081/"
|
||||
# Tasmota status endpoint
|
||||
curl -s -m2 "http://DEVICE_IP/cm?cmnd=Status%200"
|
||||
```
|
||||
|
||||
## Phase 3: Enable Local Control
|
||||
|
||||
### Stock Sonoff (eWeLink)
|
||||
1. In eWeLink app: ⚙️ Settings → **LAN Control** → ON
|
||||
2. This enables the local TCP API on port 8081
|
||||
3. Now HA's **Sonoff LAN** integration can auto-discover it
|
||||
|
||||
#### Troubleshooting LAN Mode
|
||||
|
||||
**Symptom: port 8081 accepts TCP but never responds.** The device completes the TCP handshake (SYN-ACK) but no application-layer data is returned — HTTP, WebSocket, and raw protocol requests all time out silently. This means the LAN API service process on the device is hung or was never started, even though the port shows as listening.
|
||||
|
||||
**Fix:** Power-cycle the device. Unplug for 10 seconds and plug back in. After reboot, verify:
|
||||
|
||||
```bash
|
||||
# TCP must succeed
|
||||
nc -zv -w3 DEVICE_IP 8081
|
||||
# Must return JSON (not timeout)
|
||||
curl -s --max-time 3 http://DEVICE_IP:8081/zeroconf/info
|
||||
```
|
||||
|
||||
If `zeroconf/info` returns a JSON payload with `deviceid`, `model`, `sw_version` etc., LAN mode is working correctly.
|
||||
|
||||
### Tasmota
|
||||
- Already local. Set MQTT or use the HTTP API directly.
|
||||
- Default web UI at `http://DEVICE_IP/`
|
||||
|
||||
## Phase 4: Add to Home Assistant
|
||||
|
||||
1. **Sonoff LAN** integration: Settings → Devices & Services → Add Integration → Sonoff LAN → auto-discovers
|
||||
2. **Tasmota** integration: Settings → Add Integration → Tasmota (auto-discovers via MQTT or HTTP)
|
||||
3. **ESPHome**: Add via ESPHome dashboard or manual YAML
|
||||
|
||||
Manual MQTT discovery also works for all three.
|
||||
|
||||
## Sonoff S31-Specific Notes
|
||||
|
||||
- **Model**: S31 Lite (WiFi smart plug with power monitoring)
|
||||
- **Flash method**: tuya-convert (OTA) or serial (TX/RX/GND/3.3V — requires opening case)
|
||||
- **Stock LAN API port**: 8081 (TCP)
|
||||
- **Tasmota template**: `{"NAME":"Sonoff S31","GPIO":[0,0,0,0,0,0,0,0,0,0,0,0,0,0],"FLAG":0,"BASE":18}`
|
||||
- **Power monitoring**: HLW8012 chip — reports voltage, current, power, energy
|
||||
Reference in New Issue
Block a user