[ci skip] Add ha-sofia Home Assistant deployment to skills
- Update home-assistant skill to v2.0.0 covering both ha-london and ha-sofia - Add separate API script for ha-sofia (home-assistant-sofia.py) - ha-sofia: SSH via vbarzin@ha-sofia.viktorbarzin.lan, config at /config/ - Update CLAUDE.md with both HA deployments
This commit is contained in:
parent
a26fdd27b2
commit
191c760b94
3 changed files with 452 additions and 14 deletions
|
|
@ -356,9 +356,10 @@ jellyfin, jellyseerr, tdarr, affine
|
|||
|
||||
### Home Assistant
|
||||
- **Default smart home**: Home Assistant (always use for smart home control)
|
||||
- **HA URL**: `https://ha-london.viktorbarzin.me`
|
||||
- **Script**: `.claude/home-assistant.py`
|
||||
- **Aliases**: "ha" or "HA" = Home Assistant
|
||||
- **Two deployments**:
|
||||
- **ha-london** (default): `https://ha-london.viktorbarzin.me` | Script: `.claude/home-assistant.py`
|
||||
- **ha-sofia**: `https://ha-sofia.viktorbarzin.me` | Script: `.claude/home-assistant-sofia.py` | SSH: `ssh vbarzin@ha-sofia.viktorbarzin.lan` (resolve via `192.168.1.2`), config at `/config/`
|
||||
- **Aliases**: "ha" or "HA" = ha-london. "ha sofia" or "ha-sofia" = ha-sofia.
|
||||
|
||||
### Development
|
||||
- **Frontend framework**: Svelte (user is learning it, so use Svelte for all new web apps)
|
||||
|
|
|
|||
373
.claude/home-assistant-sofia.py
Normal file
373
.claude/home-assistant-sofia.py
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Home Assistant API Script (ha-sofia instance)
|
||||
Control and query Home Assistant entities on ha-sofia.viktorbarzin.me.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urljoin
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("ERROR: Required package not installed. Run:")
|
||||
print(" pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
# Configuration from environment variables (ha-sofia specific)
|
||||
HA_URL = os.environ.get("HOME_ASSISTANT_SOFIA_URL", "").rstrip("/")
|
||||
HA_TOKEN = os.environ.get("HOME_ASSISTANT_SOFIA_TOKEN")
|
||||
|
||||
if not HA_URL or not HA_TOKEN:
|
||||
print("ERROR: HOME_ASSISTANT_SOFIA_URL and HOME_ASSISTANT_SOFIA_TOKEN environment variables must be set.")
|
||||
print("These should be set when activating the Claude venv (~/.venvs/claude)")
|
||||
sys.exit(1)
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {HA_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def api_get(endpoint):
|
||||
"""Make GET request to HA API."""
|
||||
url = f"{HA_URL}/api/{endpoint}"
|
||||
response = requests.get(url, headers=HEADERS, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def api_post(endpoint, data=None):
|
||||
"""Make POST request to HA API."""
|
||||
url = f"{HA_URL}/api/{endpoint}"
|
||||
response = requests.post(url, headers=HEADERS, json=data or {}, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json() if response.text else {}
|
||||
|
||||
|
||||
def get_states():
|
||||
"""Get all entity states."""
|
||||
return api_get("states")
|
||||
|
||||
|
||||
def get_state(entity_id):
|
||||
"""Get state of a specific entity."""
|
||||
return api_get(f"states/{entity_id}")
|
||||
|
||||
|
||||
def get_services():
|
||||
"""Get all available services."""
|
||||
return api_get("services")
|
||||
|
||||
|
||||
def call_service(domain, service, entity_id=None, data=None):
|
||||
"""Call a Home Assistant service."""
|
||||
payload = data or {}
|
||||
if entity_id:
|
||||
payload["entity_id"] = entity_id
|
||||
return api_post(f"services/{domain}/{service}", payload)
|
||||
|
||||
|
||||
def list_entities(domain_filter=None, area_filter=None):
|
||||
"""List all entities, optionally filtered by domain or area."""
|
||||
states = get_states()
|
||||
entities = []
|
||||
|
||||
for state in states:
|
||||
entity_id = state["entity_id"]
|
||||
domain = entity_id.split(".")[0]
|
||||
|
||||
if domain_filter and domain != domain_filter:
|
||||
continue
|
||||
|
||||
entities.append({
|
||||
"entity_id": entity_id,
|
||||
"state": state["state"],
|
||||
"friendly_name": state["attributes"].get("friendly_name", entity_id),
|
||||
"domain": domain,
|
||||
})
|
||||
|
||||
# Sort by domain, then entity_id
|
||||
entities.sort(key=lambda x: (x["domain"], x["entity_id"]))
|
||||
return entities
|
||||
|
||||
|
||||
def turn_on(entity_id):
|
||||
"""Turn on an entity."""
|
||||
domain = entity_id.split(".")[0]
|
||||
return call_service(domain, "turn_on", entity_id)
|
||||
|
||||
|
||||
def turn_off(entity_id):
|
||||
"""Turn off an entity."""
|
||||
domain = entity_id.split(".")[0]
|
||||
return call_service(domain, "turn_off", entity_id)
|
||||
|
||||
|
||||
def toggle(entity_id):
|
||||
"""Toggle an entity."""
|
||||
domain = entity_id.split(".")[0]
|
||||
return call_service(domain, "toggle", entity_id)
|
||||
|
||||
|
||||
def set_value(entity_id, value):
|
||||
"""Set value for input entities (input_number, input_text, etc.)."""
|
||||
domain = entity_id.split(".")[0]
|
||||
|
||||
if domain == "input_number":
|
||||
return call_service(domain, "set_value", entity_id, {"value": float(value)})
|
||||
elif domain == "input_text":
|
||||
return call_service(domain, "set_value", entity_id, {"value": str(value)})
|
||||
elif domain == "input_boolean":
|
||||
if value.lower() in ("true", "on", "1", "yes"):
|
||||
return turn_on(entity_id)
|
||||
else:
|
||||
return turn_off(entity_id)
|
||||
elif domain == "input_select":
|
||||
return call_service(domain, "select_option", entity_id, {"option": str(value)})
|
||||
elif domain == "light":
|
||||
# Assume value is brightness percentage
|
||||
return call_service(domain, "turn_on", entity_id, {"brightness_pct": int(value)})
|
||||
elif domain == "climate":
|
||||
return call_service(domain, "set_temperature", entity_id, {"temperature": float(value)})
|
||||
elif domain == "cover":
|
||||
return call_service(domain, "set_cover_position", entity_id, {"position": int(value)})
|
||||
else:
|
||||
print(f"Warning: set_value not implemented for domain '{domain}'", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def run_script(script_id):
|
||||
"""Run a script."""
|
||||
if not script_id.startswith("script."):
|
||||
script_id = f"script.{script_id}"
|
||||
return call_service("script", "turn_on", script_id)
|
||||
|
||||
|
||||
def run_scene(scene_id):
|
||||
"""Activate a scene."""
|
||||
if not scene_id.startswith("scene."):
|
||||
scene_id = f"scene.{scene_id}"
|
||||
return call_service("scene", "turn_on", scene_id)
|
||||
|
||||
|
||||
def send_notification(message, title=None, target="notify"):
|
||||
"""Send a notification."""
|
||||
data = {"message": message}
|
||||
if title:
|
||||
data["title"] = title
|
||||
return call_service("notify", target, data=data)
|
||||
|
||||
|
||||
def format_entities(entities, output_format="text"):
|
||||
"""Format entities for display."""
|
||||
if output_format == "json":
|
||||
return json.dumps(entities, indent=2)
|
||||
|
||||
if not entities:
|
||||
return "No entities found."
|
||||
|
||||
lines = []
|
||||
current_domain = None
|
||||
|
||||
for entity in entities:
|
||||
if entity["domain"] != current_domain:
|
||||
current_domain = entity["domain"]
|
||||
lines.append(f"\n## {current_domain}")
|
||||
|
||||
state = entity["state"]
|
||||
name = entity["friendly_name"]
|
||||
eid = entity["entity_id"]
|
||||
|
||||
# Color-code common states
|
||||
if state in ("on", "home", "open", "playing"):
|
||||
state_display = f"[ON] {state}"
|
||||
elif state in ("off", "away", "closed", "idle", "paused"):
|
||||
state_display = f"[--] {state}"
|
||||
elif state == "unavailable":
|
||||
state_display = "[??] unavailable"
|
||||
else:
|
||||
state_display = state
|
||||
|
||||
lines.append(f"- {name}: {state_display}")
|
||||
lines.append(f" `{eid}`")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def search_entities(query):
|
||||
"""Search entities by name or ID."""
|
||||
query = query.lower()
|
||||
states = get_states()
|
||||
matches = []
|
||||
|
||||
for state in states:
|
||||
entity_id = state["entity_id"]
|
||||
friendly_name = state["attributes"].get("friendly_name", "").lower()
|
||||
|
||||
if query in entity_id.lower() or query in friendly_name:
|
||||
matches.append({
|
||||
"entity_id": entity_id,
|
||||
"state": state["state"],
|
||||
"friendly_name": state["attributes"].get("friendly_name", entity_id),
|
||||
"domain": entity_id.split(".")[0],
|
||||
})
|
||||
|
||||
matches.sort(key=lambda x: (x["domain"], x["entity_id"]))
|
||||
return matches
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Control Home Assistant (ha-sofia)")
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# List command
|
||||
list_parser = subparsers.add_parser("list", help="List entities")
|
||||
list_parser.add_argument("--domain", "-d", help="Filter by domain (light, switch, sensor, etc.)")
|
||||
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
# Search command
|
||||
search_parser = subparsers.add_parser("search", help="Search entities")
|
||||
search_parser.add_argument("query", help="Search query")
|
||||
search_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
# State command
|
||||
state_parser = subparsers.add_parser("state", help="Get entity state")
|
||||
state_parser.add_argument("entity_id", help="Entity ID")
|
||||
state_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
# On command
|
||||
on_parser = subparsers.add_parser("on", help="Turn on entity")
|
||||
on_parser.add_argument("entity_id", help="Entity ID")
|
||||
|
||||
# Off command
|
||||
off_parser = subparsers.add_parser("off", help="Turn off entity")
|
||||
off_parser.add_argument("entity_id", help="Entity ID")
|
||||
|
||||
# Toggle command
|
||||
toggle_parser = subparsers.add_parser("toggle", help="Toggle entity")
|
||||
toggle_parser.add_argument("entity_id", help="Entity ID")
|
||||
|
||||
# Set command
|
||||
set_parser = subparsers.add_parser("set", help="Set entity value")
|
||||
set_parser.add_argument("entity_id", help="Entity ID")
|
||||
set_parser.add_argument("value", help="Value to set")
|
||||
|
||||
# Script command
|
||||
script_parser = subparsers.add_parser("script", help="Run a script")
|
||||
script_parser.add_argument("script_id", help="Script ID (with or without 'script.' prefix)")
|
||||
|
||||
# Scene command
|
||||
scene_parser = subparsers.add_parser("scene", help="Activate a scene")
|
||||
scene_parser.add_argument("scene_id", help="Scene ID (with or without 'scene.' prefix)")
|
||||
|
||||
# Service command
|
||||
service_parser = subparsers.add_parser("service", help="Call a service")
|
||||
service_parser.add_argument("domain", help="Service domain")
|
||||
service_parser.add_argument("service", help="Service name")
|
||||
service_parser.add_argument("--entity", "-e", help="Entity ID")
|
||||
service_parser.add_argument("--data", "-d", help="JSON data")
|
||||
|
||||
# Services list command
|
||||
services_parser = subparsers.add_parser("services", help="List available services")
|
||||
services_parser.add_argument("--domain", "-d", help="Filter by domain")
|
||||
services_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
# Notify command
|
||||
notify_parser = subparsers.add_parser("notify", help="Send notification")
|
||||
notify_parser.add_argument("message", help="Notification message")
|
||||
notify_parser.add_argument("--title", "-t", help="Notification title")
|
||||
notify_parser.add_argument("--target", default="notify", help="Notification target (default: notify)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
if args.command == "list":
|
||||
entities = list_entities(domain_filter=args.domain)
|
||||
output_format = "json" if args.json else "text"
|
||||
print(format_entities(entities, output_format))
|
||||
|
||||
elif args.command == "search":
|
||||
entities = search_entities(args.query)
|
||||
output_format = "json" if args.json else "text"
|
||||
print(format_entities(entities, output_format))
|
||||
|
||||
elif args.command == "state":
|
||||
state = get_state(args.entity_id)
|
||||
if args.json:
|
||||
print(json.dumps(state, indent=2))
|
||||
else:
|
||||
print(f"Entity: {state['entity_id']}")
|
||||
print(f"State: {state['state']}")
|
||||
print(f"Name: {state['attributes'].get('friendly_name', 'N/A')}")
|
||||
if state['attributes']:
|
||||
print("Attributes:")
|
||||
for key, value in state['attributes'].items():
|
||||
if key != 'friendly_name':
|
||||
print(f" {key}: {value}")
|
||||
|
||||
elif args.command == "on":
|
||||
turn_on(args.entity_id)
|
||||
print(f"Turned on: {args.entity_id}")
|
||||
|
||||
elif args.command == "off":
|
||||
turn_off(args.entity_id)
|
||||
print(f"Turned off: {args.entity_id}")
|
||||
|
||||
elif args.command == "toggle":
|
||||
toggle(args.entity_id)
|
||||
print(f"Toggled: {args.entity_id}")
|
||||
|
||||
elif args.command == "set":
|
||||
set_value(args.entity_id, args.value)
|
||||
print(f"Set {args.entity_id} to {args.value}")
|
||||
|
||||
elif args.command == "script":
|
||||
run_script(args.script_id)
|
||||
print(f"Ran script: {args.script_id}")
|
||||
|
||||
elif args.command == "scene":
|
||||
run_scene(args.scene_id)
|
||||
print(f"Activated scene: {args.scene_id}")
|
||||
|
||||
elif args.command == "service":
|
||||
data = json.loads(args.data) if args.data else None
|
||||
call_service(args.domain, args.service, args.entity, data)
|
||||
print(f"Called {args.domain}.{args.service}")
|
||||
|
||||
elif args.command == "services":
|
||||
services = get_services()
|
||||
if args.domain:
|
||||
services = [s for s in services if s["domain"] == args.domain]
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(services, indent=2))
|
||||
else:
|
||||
for svc in services:
|
||||
print(f"\n## {svc['domain']}")
|
||||
for name, info in svc["services"].items():
|
||||
desc = info.get("description", "")
|
||||
print(f"- {name}: {desc[:60]}...")
|
||||
|
||||
elif args.command == "notify":
|
||||
send_notification(args.message, args.title, args.target)
|
||||
print(f"Sent notification: {args.message[:50]}...")
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"HTTP Error: {e}", file=sys.stderr)
|
||||
print(f"Response: {e.response.text}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -8,10 +8,11 @@ description: |
|
|||
(4) User asks to run a scene or script,
|
||||
(5) User asks "what devices are on?" or "is the door locked?",
|
||||
(6) User mentions smart home, IoT, or home automation.
|
||||
There are TWO Home Assistant deployments: ha-london (default) and ha-sofia.
|
||||
Always use Home Assistant for smart home control.
|
||||
author: Claude Code
|
||||
version: 1.0.0
|
||||
date: 2025-01-25
|
||||
version: 2.0.0
|
||||
date: 2026-02-07
|
||||
---
|
||||
|
||||
# Home Assistant Control
|
||||
|
|
@ -26,22 +27,42 @@ Need to control smart home devices, check sensor states, or run automations via
|
|||
- User mentions turning things on/off
|
||||
- User asks about smart home devices
|
||||
|
||||
## Deployments
|
||||
|
||||
There are **two** Home Assistant instances:
|
||||
|
||||
| Instance | URL | SSH | Default? |
|
||||
|----------|-----|-----|----------|
|
||||
| **ha-london** | `https://ha-london.viktorbarzin.me` | N/A (runs on K8s cluster) | Yes |
|
||||
| **ha-sofia** | `https://ha-sofia.viktorbarzin.me` | `ssh vbarzin@ha-sofia.viktorbarzin.lan` (resolve via `192.168.1.2`) | No |
|
||||
|
||||
- **Default**: ha-london (use unless user specifies "sofia" or "ha-sofia")
|
||||
- **Aliases**: "ha" or "HA" = ha-london. "ha sofia" or "ha-sofia" = ha-sofia.
|
||||
|
||||
## Prerequisites
|
||||
- The `~/.venvs/claude` virtualenv must have `requests` package installed
|
||||
- Environment variables `HOME_ASSISTANT_URL` and `HOME_ASSISTANT_TOKEN` must be set in the venv activation script
|
||||
- Environment variables for each instance must be set in the venv activation script:
|
||||
- **ha-london**: `HOME_ASSISTANT_URL` and `HOME_ASSISTANT_TOKEN`
|
||||
- **ha-sofia**: `HOME_ASSISTANT_SOFIA_URL` and `HOME_ASSISTANT_SOFIA_TOKEN`
|
||||
|
||||
## Solution
|
||||
## API Control
|
||||
|
||||
### Script Location
|
||||
```
|
||||
/home/wizard/code/infra/.claude/home-assistant.py
|
||||
```
|
||||
### Scripts
|
||||
|
||||
| Instance | Script |
|
||||
|----------|--------|
|
||||
| ha-london | `.claude/home-assistant.py` |
|
||||
| ha-sofia | `.claude/home-assistant-sofia.py` |
|
||||
|
||||
### Execution Pattern (CRITICAL)
|
||||
Always activate the venv to get environment variables:
|
||||
|
||||
```bash
|
||||
# ha-london (default)
|
||||
source ~/.venvs/claude/bin/activate && cd ~/code/infra && python .claude/home-assistant.py [command] [options]
|
||||
|
||||
# ha-sofia
|
||||
source ~/.venvs/claude/bin/activate && cd ~/code/infra && python .claude/home-assistant-sofia.py [command] [options]
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
|
|
@ -129,14 +150,56 @@ python .claude/home-assistant.py notify "Motion detected" --title "Security Aler
|
|||
python .claude/home-assistant.py notify "Hello" --target notify.mobile_app
|
||||
```
|
||||
|
||||
## SSH Access (ha-sofia only)
|
||||
|
||||
ha-sofia supports SSH for direct configuration management.
|
||||
|
||||
### Connection
|
||||
```bash
|
||||
# DNS resolves via 192.168.1.2
|
||||
ssh vbarzin@ha-sofia.viktorbarzin.lan
|
||||
```
|
||||
|
||||
If DNS resolution fails (e.g., not on the local network), use the DNS server directly:
|
||||
```bash
|
||||
ssh vbarzin@$(dig +short ha-sofia.viktorbarzin.lan @192.168.1.2)
|
||||
```
|
||||
|
||||
### Configuration Path
|
||||
```
|
||||
/config/
|
||||
```
|
||||
|
||||
### Common SSH Tasks
|
||||
```bash
|
||||
# Edit configuration
|
||||
ssh vbarzin@ha-sofia.viktorbarzin.lan "cat /config/configuration.yaml"
|
||||
|
||||
# Check HA logs
|
||||
ssh vbarzin@ha-sofia.viktorbarzin.lan "cat /config/home-assistant.log | tail -50"
|
||||
|
||||
# List automations
|
||||
ssh vbarzin@ha-sofia.viktorbarzin.lan "ls /config/automations.yaml"
|
||||
|
||||
# Restart HA (after config changes)
|
||||
ssh vbarzin@ha-sofia.viktorbarzin.lan "ha core restart"
|
||||
|
||||
# Check config validity
|
||||
ssh vbarzin@ha-sofia.viktorbarzin.lan "ha core check"
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
To turn on the living room light:
|
||||
|
||||
To turn on the living room light on ha-london:
|
||||
```bash
|
||||
source ~/.venvs/claude/bin/activate && cd ~/code/infra && python .claude/home-assistant.py on light.living_room
|
||||
```
|
||||
|
||||
To check ha-sofia configuration:
|
||||
```bash
|
||||
ssh vbarzin@ha-sofia.viktorbarzin.lan "cat /config/configuration.yaml"
|
||||
```
|
||||
|
||||
## Common Entity Domains
|
||||
|
||||
| Domain | Description | Common Actions |
|
||||
|
|
@ -175,4 +238,5 @@ source ~/.venvs/claude/bin/activate && cd ~/code/infra && python .claude/home-as
|
|||
1. **Entity IDs are case-sensitive** - use `search` to find exact IDs
|
||||
2. **Token must have sufficient permissions** - ensure token has access to all entities
|
||||
3. **Some entities require specific data** - use `services` command to see required fields
|
||||
4. **HA URL**: `https://ha-london.viktorbarzin.me`
|
||||
4. **Two instances**: ha-london (default, K8s), ha-sofia (SSH + API)
|
||||
5. **ha-sofia SSH**: Uses default SSH key, user `vbarzin`, resolve DNS via `192.168.1.2`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue