From fa9e6cc72637678f3838c2a5f6a8a7d992cd3e22 Mon Sep 17 00:00:00 2001 From: bahmcloud Date: Wed, 14 Jan 2026 15:52:55 +0000 Subject: [PATCH] Dateien nach "custom_components/proxmox_pve" hochladen --- custom_components/proxmox_pve/services.py | 234 ++++++++++++++++++++ custom_components/proxmox_pve/services.yaml | 99 +++++++++ custom_components/proxmox_pve/switch.py | 184 +++++++++++++++ 3 files changed, 517 insertions(+) create mode 100644 custom_components/proxmox_pve/services.py create mode 100644 custom_components/proxmox_pve/services.yaml create mode 100644 custom_components/proxmox_pve/switch.py diff --git a/custom_components/proxmox_pve/services.py b/custom_components/proxmox_pve/services.py new file mode 100644 index 0000000..80e4b43 --- /dev/null +++ b/custom_components/proxmox_pve/services.py @@ -0,0 +1,234 @@ +import logging +from typing import Any, Tuple + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SERVICE_START = "start" +SERVICE_SHUTDOWN = "shutdown" +SERVICE_STOP_HARD = "stop_hard" +SERVICE_REBOOT = "reboot" + +ATTR_DEVICE_ID = "device_id" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_HOST = "host" +ATTR_NODE = "node" +ATTR_VMID = "vmid" +ATTR_TYPE = "type" + +VALID_TYPES = ("qemu", "lxc") + +SERVICE_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_DEVICE_ID): str, + vol.Optional(ATTR_CONFIG_ENTRY_ID): str, + vol.Optional(ATTR_HOST): str, + vol.Optional(ATTR_NODE): str, + vol.Optional(ATTR_VMID): vol.Coerce(int), + vol.Optional(ATTR_TYPE, default="qemu"): vol.In(VALID_TYPES), + } +) + + +def _parse_guest_identifier(identifier: str) -> Tuple[str, str, int]: + """ + Guest device identifier format: "node:type:vmid" + Example: "pve1:qemu:100" + """ + parts = identifier.split(":") + if len(parts) != 3: + raise ValueError(f"Invalid guest identifier: {identifier}") + node, vmtype, vmid_s = parts + vmid = int(vmid_s) + if vmtype not in VALID_TYPES: + raise ValueError(f"Invalid VM type: {vmtype}") + return node, vmtype, vmid + + +def _resolve_target(hass: HomeAssistant, call: ServiceCall) -> Tuple[str, str, int]: + """Resolve node/type/vmid from device_id OR node+vmid (+ optional type).""" + device_id = call.data.get(ATTR_DEVICE_ID) + node = call.data.get(ATTR_NODE) + vmid = call.data.get(ATTR_VMID) + vmtype = call.data.get(ATTR_TYPE, "qemu") + + if device_id: + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(device_id) + if not device: + raise ValueError(f"Device not found: {device_id}") + + # Find our guest identifier in device.identifiers + for ident_domain, ident_value in device.identifiers: + if ident_domain != DOMAIN: + continue + # Node devices are "node:" — ignore those + if ident_value.startswith("node:"): + continue + return _parse_guest_identifier(ident_value) + + raise ValueError(f"Selected device has no Easy Proxmox guest identifier: {device_id}") + + # manual mode + if not node or vmid is None: + raise ValueError("Provide either device_id OR node + vmid (+ optional type/host/config_entry_id).") + + if vmtype not in VALID_TYPES: + raise ValueError(f"Invalid type: {vmtype} (allowed: {VALID_TYPES})") + + return str(node), str(vmtype), int(vmid) + + +def _get_domain_entries(hass: HomeAssistant) -> dict[str, Any]: + domain_data: dict[str, Any] = hass.data.get(DOMAIN, {}) + if not domain_data: + raise ValueError("Easy Proxmox is not set up.") + return domain_data + + +def _pick_entry_id_for_device(hass: HomeAssistant, device_id: str) -> str: + """Pick correct config_entry_id by using device.config_entries.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(device_id) + if not device: + raise ValueError(f"Device not found: {device_id}") + + domain_entries = _get_domain_entries(hass) + candidates = [eid for eid in device.config_entries if eid in domain_entries] + if not candidates: + raise ValueError("Device is not linked to any loaded Easy Proxmox config entry.") + if len(candidates) > 1: + # Very unlikely, but handle it + _LOGGER.warning("Device %s belongs to multiple Easy Proxmox entries, using first.", device_id) + return candidates[0] + + +def _pick_entry_id_by_host(hass: HomeAssistant, host: str) -> str: + domain_entries = _get_domain_entries(hass) + matches = [] + for entry_id, data in domain_entries.items(): + if not isinstance(data, dict): + continue + # host is stored in entry.data, but we keep it accessible here via "client.host" too + client = data.get("client") + if client and getattr(client, "host", None) == host: + matches.append(entry_id) + + if not matches: + raise ValueError(f"No Easy Proxmox entry found for host '{host}'.") + if len(matches) > 1: + raise ValueError(f"Multiple Easy Proxmox entries found for host '{host}'. Please use config_entry_id.") + return matches[0] + + +def _pick_entry_id_by_guest_lookup(hass: HomeAssistant, node: str, vmtype: str, vmid: int) -> str: + """ + If user provides only node/vmid/type, try to find the correct entry by + scanning each entry's cluster resources list. + """ + domain_entries = _get_domain_entries(hass) + matches = [] + + for entry_id, data in domain_entries.items(): + if not isinstance(data, dict): + continue + resources = data.get("resources") + res_list = getattr(resources, "data", None) + if not res_list: + continue + + for r in res_list: + try: + if r.get("type") == vmtype and str(r.get("node")) == node and int(r.get("vmid")) == vmid: + matches.append(entry_id) + break + except Exception: + continue + + if not matches: + raise ValueError( + f"Could not find guest {node}/{vmtype}/{vmid} in any configured Proxmox host. " + "Provide host or config_entry_id." + ) + if len(matches) > 1: + raise ValueError( + f"Guest {node}/{vmtype}/{vmid} exists on multiple configured hosts (ambiguous). " + "Please provide host or config_entry_id, or use device_id." + ) + return matches[0] + + +def _resolve_entry_id(hass: HomeAssistant, call: ServiceCall, target: Tuple[str, str, int]) -> str: + """Resolve which config entry should execute this service call.""" + domain_entries = _get_domain_entries(hass) + + # 1) explicit config_entry_id + config_entry_id = call.data.get(ATTR_CONFIG_ENTRY_ID) + if config_entry_id: + if config_entry_id not in domain_entries: + raise ValueError(f"config_entry_id '{config_entry_id}' not found or not loaded.") + return config_entry_id + + # 2) by device_id (best + unambiguous) + device_id = call.data.get(ATTR_DEVICE_ID) + if device_id: + return _pick_entry_id_for_device(hass, device_id) + + # 3) by host + host = call.data.get(ATTR_HOST) + if host: + return _pick_entry_id_by_host(hass, host) + + # 4) last resort: guest lookup in resources list + node, vmtype, vmid = target + return _pick_entry_id_by_guest_lookup(hass, node, vmtype, vmid) + + +async def async_register_services(hass: HomeAssistant) -> None: + """Register services once per HA instance.""" + if hass.services.has_service(DOMAIN, SERVICE_START): + return + + async def _call_action(call: ServiceCall, action: str) -> None: + node, vmtype, vmid = _resolve_target(hass, call) + entry_id = _resolve_entry_id(hass, call, (node, vmtype, vmid)) + + domain_entries = _get_domain_entries(hass) + entry_data = domain_entries.get(entry_id) + if not isinstance(entry_data, dict) or not entry_data.get("client"): + raise ValueError(f"Selected config entry '{entry_id}' has no client (not loaded).") + + client = entry_data["client"] + + _LOGGER.debug("Service action=%s entry=%s target=%s/%s/%s", action, entry_id, node, vmtype, vmid) + await client.guest_action(node=node, vmid=vmid, vmtype=vmtype, action=action) + + async def handle_start(call: ServiceCall) -> None: + await _call_action(call, "start") + + async def handle_shutdown(call: ServiceCall) -> None: + await _call_action(call, "shutdown") + + async def handle_stop_hard(call: ServiceCall) -> None: + await _call_action(call, "stop") + + async def handle_reboot(call: ServiceCall) -> None: + await _call_action(call, "reboot") + + hass.services.async_register(DOMAIN, SERVICE_START, handle_start, schema=SERVICE_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_SHUTDOWN, handle_shutdown, schema=SERVICE_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_STOP_HARD, handle_stop_hard, schema=SERVICE_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_REBOOT, handle_reboot, schema=SERVICE_SCHEMA) + + +async def async_unregister_services(hass: HomeAssistant) -> None: + """Unregister services (optional cleanup).""" + for svc in (SERVICE_START, SERVICE_SHUTDOWN, SERVICE_STOP_HARD, SERVICE_REBOOT): + if hass.services.has_service(DOMAIN, svc): + hass.services.async_remove(DOMAIN, svc) diff --git a/custom_components/proxmox_pve/services.yaml b/custom_components/proxmox_pve/services.yaml new file mode 100644 index 0000000..370e009 --- /dev/null +++ b/custom_components/proxmox_pve/services.yaml @@ -0,0 +1,99 @@ +start: + name: Start guest + description: Start a VM or container. + fields: + device_id: + name: Device + description: Select the VM/CT device (recommended, works with multi-host). + example: "a1b2c3d4e5f6..." + config_entry_id: + name: Config entry id + description: Optional. Force a specific Proxmox host configuration entry. + host: + name: Host + description: Optional. Proxmox host/IP of the configured entry (only used when device_id is not provided). + example: "192.168.178.101" + node: + name: Node + description: Proxmox node name (only used when device_id is not provided). + example: "pve1" + vmid: + name: VMID + description: Guest VMID (only used when device_id is not provided). + example: 100 + type: + name: Type + description: Guest type. + example: "qemu" + +shutdown: + name: Shutdown guest + description: Soft shutdown a VM or container. + fields: + device_id: + name: Device + description: Select the VM/CT device (recommended, works with multi-host). + config_entry_id: + name: Config entry id + description: Optional. Force a specific Proxmox host configuration entry. + host: + name: Host + description: Optional. Proxmox host/IP of the configured entry (only used when device_id is not provided). + node: + name: Node + description: Proxmox node name (only used when device_id is not provided). + vmid: + name: VMID + description: Guest VMID (only used when device_id is not provided). + type: + name: Type + description: Guest type. + example: "qemu" + +stop_hard: + name: Stop guest (hard) + description: Hard stop a VM or container (equivalent to Stop). + fields: + device_id: + name: Device + description: Select the VM/CT device (recommended, works with multi-host). + config_entry_id: + name: Config entry id + description: Optional. Force a specific Proxmox host configuration entry. + host: + name: Host + description: Optional. Proxmox host/IP of the configured entry (only used when device_id is not provided). + node: + name: Node + description: Proxmox node name (only used when device_id is not provided). + vmid: + name: VMID + description: Guest VMID (only used when device_id is not provided). + type: + name: Type + description: Guest type. + example: "qemu" + +reboot: + name: Reboot guest + description: Reboot a VM or container. + fields: + device_id: + name: Device + description: Select the VM/CT device (recommended, works with multi-host). + config_entry_id: + name: Config entry id + description: Optional. Force a specific Proxmox host configuration entry. + host: + name: Host + description: Optional. Proxmox host/IP of the configured entry (only used when device_id is not provided). + node: + name: Node + description: Proxmox node name (only used when device_id is not provided). + vmid: + name: VMID + description: Guest VMID (only used when device_id is not provided). + type: + name: Type + description: Guest type. + example: "qemu" diff --git a/custom_components/proxmox_pve/switch.py b/custom_components/proxmox_pve/switch.py new file mode 100644 index 0000000..0af53da --- /dev/null +++ b/custom_components/proxmox_pve/switch.py @@ -0,0 +1,184 @@ +import asyncio +import logging + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ProxmoxGuestCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def _guest_display_name(resource: dict) -> str: + name = resource.get("name") or f"{resource.get('type')} {resource.get('vmid')}" + return f"{name} (VMID {resource.get('vmid')})" + + +def _guest_id(resource: dict) -> str: + return f"{resource.get('node')}:{resource.get('type')}:{resource.get('vmid')}" + + +def _guest_key(resource: dict) -> tuple[str, str, int]: + return (resource["node"], resource["type"], int(resource["vmid"])) + + +def _model_for(resource: dict) -> str: + return "Virtual Machine" if resource.get("type") == "qemu" else "Container" + + +async def _get_guest_coordinator(hass: HomeAssistant, entry: ConfigEntry, resource: dict) -> ProxmoxGuestCoordinator: + data = hass.data[DOMAIN][entry.entry_id] + key = _guest_key(resource) + + if key in data["guest_coordinators"]: + return data["guest_coordinators"][key] + + coord = ProxmoxGuestCoordinator( + hass=hass, + client=data["client"], + node=key[0], + vmtype=key[1], + vmid=key[2], + scan_interval=int(data["scan_interval"]), + ip_mode=str(data["ip_mode"]), + ip_prefix=str(data["ip_prefix"]), + ) + data["guest_coordinators"][key] = coord + hass.async_create_task(coord.async_config_entry_first_refresh()) + return coord + + +def _update_device_name(hass: HomeAssistant, guest_id: str, new_name: str, model: str) -> None: + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device(identifiers={(DOMAIN, guest_id)}) + if device and (device.name != new_name or device.model != model): + dev_reg.async_update_device(device.id, name=new_name, model=model) + + +async def _purge_guest_entity_registry(hass: HomeAssistant, entry: ConfigEntry, guest_id: str) -> None: + ent_reg = er.async_get(hass) + prefix = f"{entry.entry_id}_{guest_id}_" + + to_remove: list[str] = [] + for entity_id, ent in ent_reg.entities.items(): + if ent.config_entry_id != entry.entry_id: + continue + if ent.unique_id and ent.unique_id.startswith(prefix): + to_remove.append(entity_id) + + for entity_id in to_remove: + ent_reg.async_remove(entity_id) + + await asyncio.sleep(0) + + +async def _remove_device(hass: HomeAssistant, guest_id: str) -> None: + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device(identifiers={(DOMAIN, guest_id)}) + if device: + dev_reg.async_remove_device(device.id) + + +class ProxmoxGuestPowerSwitch(CoordinatorEntity, SwitchEntity): + _attr_icon = "mdi:power" + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator) + self._entry = entry + self._resource = dict(resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_power" + + def update_resource(self, resource: dict) -> None: + self._resource = dict(resource) + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} Power" + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, _guest_id(self._resource))}, + "name": _guest_display_name(self._resource), + "manufacturer": "Proxmox VE", + "model": _model_for(self._resource), + } + + @property + def extra_state_attributes(self): + return {"vmid": self._resource.get("vmid"), "node": self._resource.get("node"), "type": self._resource.get("type")} + + @property + def is_on(self) -> bool: + return (self.coordinator.data or {}).get("status") == "running" + + async def async_turn_on(self, **kwargs) -> None: + client = self.hass.data[DOMAIN][self._entry.entry_id]["client"] + await client.guest_action(self._resource["node"], int(self._resource["vmid"]), self._resource["type"], "start") + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs) -> None: + client = self.hass.data[DOMAIN][self._entry.entry_id]["client"] + await client.guest_action(self._resource["node"], int(self._resource["vmid"]), self._resource["type"], "shutdown") + await self.coordinator.async_request_refresh() + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: + data = hass.data[DOMAIN][entry.entry_id] + resources_coord = data["resources"] + + platform_cache = data.setdefault("platform_cache", {}) + cache: dict[tuple[str, str, int], SwitchEntity] = platform_cache.setdefault("switch", {}) + + async def _sync() -> None: + resources = resources_coord.data or [] + current: dict[tuple[str, str, int], dict] = {} + + for r in resources: + if r.get("type") not in ("qemu", "lxc"): + continue + if r.get("node") is None or r.get("vmid") is None: + continue + current[_guest_key(r)] = r + + # update + for key, r in current.items(): + if key not in cache: + continue + gid = _guest_id(r) + _update_device_name(hass, gid, _guest_display_name(r), _model_for(r)) + ent = cache[key] + ent.update_resource(r) + ent.async_write_ha_state() + + # add + new_entities: list[SwitchEntity] = [] + for key, r in current.items(): + if key in cache: + continue + guest_coord = await _get_guest_coordinator(hass, entry, r) + ent = ProxmoxGuestPowerSwitch(guest_coord, entry, r) + cache[key] = ent + new_entities.append(ent) + + if new_entities: + async_add_entities(new_entities, update_before_add=False) + + # remove (hard cleanup) + removed = [k for k in list(cache.keys()) if k not in current] + for k in removed: + gid = f"{k[0]}:{k[1]}:{k[2]}" + await cache[k].async_remove() + del cache[k] + data["guest_coordinators"].pop(k, None) + await _purge_guest_entity_registry(hass, entry, gid) + await _remove_device(hass, gid) + + await _sync() + unsub = resources_coord.async_add_listener(lambda: hass.async_create_task(_sync())) + platform_cache.setdefault("switch_unsub", []).append(unsub)