Dateien nach "custom_components/proxmox_pve" hochladen

This commit is contained in:
2026-01-14 15:52:55 +00:00
parent 1ec5c54fbb
commit b6c0750782
3 changed files with 517 additions and 0 deletions

View File

@@ -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:<name>" — 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)

View File

@@ -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"

View File

@@ -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)