diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6271dd63..4601abf0 100755 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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) diff --git a/.claude/home-assistant-sofia.py b/.claude/home-assistant-sofia.py new file mode 100644 index 00000000..b0ccdca7 --- /dev/null +++ b/.claude/home-assistant-sofia.py @@ -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() diff --git a/.claude/skills/home-assistant/SKILL.md b/.claude/skills/home-assistant/SKILL.md index 5e00e2d5..a0f303e2 100644 --- a/.claude/skills/home-assistant/SKILL.md +++ b/.claude/skills/home-assistant/SKILL.md @@ -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`