#!/usr/bin/env python3 """Generate a Home Assistant entity-ID takeover mapping for GREE Controller.""" from __future__ import annotations import argparse import json import re import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Any ENTITY_RE = re.compile(r"^climate\.[a-z0-9_]+$") def request_json(url: str, token: str = "") -> Any: headers = {"Accept": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" request = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(request, timeout=10) as response: return json.load(response) except urllib.error.HTTPError as err: detail = err.read().decode("utf-8", errors="replace") raise RuntimeError(f"HTTP {err.code}: {detail or err.reason}") from err except urllib.error.URLError as err: raise RuntimeError(f"Connection failed: {err.reason}") from err def normalize_url(value: str) -> str: value = value.strip().rstrip("/") parsed = urllib.parse.urlparse(value) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError(f"Invalid URL: {value}") return value def parse_mapping(value: str) -> tuple[str, str]: if "=" not in value: raise argparse.ArgumentTypeError("mapping must be ENTITY_ID=CONTROLLER_DEVICE_ID") entity_id, device_id = (part.strip() for part in value.split("=", 1)) if not ENTITY_RE.fullmatch(entity_id): raise argparse.ArgumentTypeError(f"invalid climate entity ID: {entity_id}") if not device_id: raise argparse.ArgumentTypeError("controller device ID cannot be empty") return entity_id, device_id def validate_ha_entity(base_url: str, token: str, entity_id: str) -> dict[str, Any]: encoded = urllib.parse.quote(entity_id, safe="") state = request_json(f"{base_url}/api/states/{encoded}", token) if not isinstance(state, dict) or state.get("entity_id") != entity_id: raise RuntimeError(f"Home Assistant did not return {entity_id}") return state def controller_devices(base_url: str, token: str) -> list[dict[str, Any]]: data = request_json(f"{base_url}/api/integrations/home-assistant/devices", token) if not isinstance(data, list): raise RuntimeError("Controller returned an invalid devices response") return [item for item in data if isinstance(item, dict) and item.get("id")] def main() -> int: parser = argparse.ArgumentParser( description=( "Generate /config/gree_controller_entities.json so the custom Home Assistant " "integration can claim an existing climate entity ID after the old integration is unloaded." ) ) parser.add_argument("--map", action="append", default=[], type=parse_mapping, metavar="ENTITY=DEVICE", help="Repeatable mapping, e.g. climate.klima_salon=gree-aabbccddeeff") parser.add_argument("--entity", help="Single existing HA climate entity, e.g. climate.klima_salon") parser.add_argument("--device", help="Controller device ID for --entity") parser.add_argument("--controller-url", help="Optional controller URL used to validate or auto-select a single device") parser.add_argument("--controller-token", default="", help="GREE Controller Home Assistant access token") parser.add_argument("--ha-url", help="Optional Home Assistant URL used to validate source entities") parser.add_argument("--ha-token", default="", help="Optional Home Assistant Long-Lived Access Token") parser.add_argument("--output", default="ha-addon/home-assistant/generated/gree_controller_entities.json", help="Output mapping file") args = parser.parse_args() mappings: list[tuple[str, str]] = list(args.map) devices: list[dict[str, Any]] = [] controller_url = normalize_url(args.controller_url) if args.controller_url else "" ha_url = normalize_url(args.ha_url) if args.ha_url else "" if controller_url: devices = controller_devices(controller_url, args.controller_token) if args.entity: if not ENTITY_RE.fullmatch(args.entity): parser.error("--entity must be a climate.* entity ID using lowercase letters, digits and underscores") device_id = (args.device or "").strip() if not device_id: if len(devices) == 1: device_id = str(devices[0]["id"]) elif not controller_url: parser.error("--device is required unless --controller-url identifies exactly one device") else: choices = ", ".join(f"{item['id']} ({item.get('name', 'unnamed')})" for item in devices) or "none" parser.error(f"--device is required because the controller exposes {len(devices)} devices: {choices}") mappings.append((args.entity, device_id)) if not mappings: parser.error("provide at least one --map or --entity") by_entity: dict[str, str] = {} by_device: dict[str, str] = {} for entity_id, device_id in mappings: if entity_id in by_entity and by_entity[entity_id] != device_id: parser.error(f"duplicate entity mapping: {entity_id}") if device_id in by_device and by_device[device_id] != entity_id: parser.error(f"one controller device cannot claim two climate entity IDs: {device_id}") by_entity[entity_id] = device_id by_device[device_id] = entity_id if devices: valid_ids = {str(item["id"]) for item in devices} missing = [device_id for device_id in by_device if device_id not in valid_ids] if missing: parser.error(f"controller device ID not found: {', '.join(missing)}") source_metadata: dict[str, dict[str, Any]] = {} if ha_url: if not args.ha_token: parser.error("--ha-token is required when --ha-url is used") for entity_id in by_entity: state = validate_ha_entity(ha_url, args.ha_token, entity_id) source_metadata[entity_id] = { "friendly_name": state.get("attributes", {}).get("friendly_name", ""), "state": state.get("state"), } payload = { "version": 1, "entities": [ { "entity_id": entity_id, "device_id": device_id, **({"source": source_metadata[entity_id]} if entity_id in source_metadata else {}), } for entity_id, device_id in sorted(by_entity.items()) ], } output = Path(args.output) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") print(f"Generated: {output}") print("Copy this file to Home Assistant as /config/gree_controller_entities.json.") print("Before adding GREE Controller in HA, verify the Rust controller can control the AC, then disable/remove the old GREE integration so the requested entity IDs are free.") print("Automations and dashboards that reference the same entity_id can then continue using it.") return 0 if __name__ == "__main__": sys.exit(main())