Files
gree-controller/scripts/generate_ha_migration.py
T
2026-08-23 21:34:07 +02:00

162 lines
7.0 KiB
Python
Executable File

#!/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="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())