3 Commits

Author SHA1 Message Date
fea7f3c4af Aktualisieren von services.py 2026-01-13 22:16:53 +01:00
ceb5353ae9 Aktualisieren von CHANGELOG.md 2026-01-13 22:00:01 +01:00
64b95c0f17 Aktualisieren von services.py 2026-01-13 21:58:27 +01:00
2 changed files with 39 additions and 47 deletions

View File

@@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.7.2
- Fixed service validation for device targets:
- Home Assistant may pass `device_id` as a list (target/data wrapper)
- Services now accept both `str` and `list[str]` for `device_id`
- Improved device target parsing for UI and script wrappers
## 0.7.1 ## 0.7.1
- Fixed Home Assistant service UI integration: - Fixed Home Assistant service UI integration:
- Services now properly expose the **Device selector** in the visual automation editor - Services now properly expose the **Device selector** in the visual automation editor

View File

@@ -15,7 +15,7 @@ SERVICE_SHUTDOWN = "shutdown"
SERVICE_STOP_HARD = "stop_hard" SERVICE_STOP_HARD = "stop_hard"
SERVICE_REBOOT = "reboot" SERVICE_REBOOT = "reboot"
ATTR_DEVICE_ID = "device_id" # fallback if user manually puts it into data ATTR_DEVICE_ID = "device_id"
ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_CONFIG_ENTRY_ID = "config_entry_id"
ATTR_HOST = "host" ATTR_HOST = "host"
ATTR_NODE = "node" ATTR_NODE = "node"
@@ -24,10 +24,10 @@ ATTR_TYPE = "type"
VALID_TYPES = ("qemu", "lxc") VALID_TYPES = ("qemu", "lxc")
# NOTE: device selection in UI goes via call.target, not via call.data. # HA may pass device_id as str or list[str] (especially when using UI targets).
SERVICE_SCHEMA = vol.Schema( SERVICE_SCHEMA = vol.Schema(
{ {
vol.Optional(ATTR_DEVICE_ID): str, vol.Optional(ATTR_DEVICE_ID): vol.Any(str, [str]),
vol.Optional(ATTR_CONFIG_ENTRY_ID): str, vol.Optional(ATTR_CONFIG_ENTRY_ID): str,
vol.Optional(ATTR_HOST): str, vol.Optional(ATTR_HOST): str,
vol.Optional(ATTR_NODE): str, vol.Optional(ATTR_NODE): str,
@@ -37,23 +37,25 @@ SERVICE_SCHEMA = vol.Schema(
) )
def _get_target_device_id(call: ServiceCall) -> str | None: def _first_str(value: Any) -> str | None:
"""Return single device_id selected in UI (call.target) or fallback call.data.""" """Return first string from str or list[str], else None."""
# UI target: {"device_id": ["..."]} if isinstance(value, str) and value.strip():
if call.target and isinstance(call.target, dict): return value.strip()
dev_ids = call.target.get("device_id") if isinstance(value, list) and value:
if isinstance(dev_ids, list) and dev_ids: v0 = value[0]
return dev_ids[0] if isinstance(v0, str) and v0.strip():
if isinstance(dev_ids, str): return v0.strip()
return dev_ids
# YAML fallback: data.device_id
dev_id = call.data.get(ATTR_DEVICE_ID)
if isinstance(dev_id, str) and dev_id.strip():
return dev_id.strip()
return None return None
def _get_device_id(call: ServiceCall) -> str | None:
"""
IMPORTANT: On some HA versions ServiceCall has no .target attribute.
UI targets are still provided, usually as data.device_id (often a list).
"""
return _first_str(call.data.get(ATTR_DEVICE_ID))
def _parse_guest_identifier(identifier: str) -> Tuple[str, str, int]: def _parse_guest_identifier(identifier: str) -> Tuple[str, str, int]:
""" """
Guest device identifier format: "node:type:vmid" Guest device identifier format: "node:type:vmid"
@@ -70,8 +72,8 @@ def _parse_guest_identifier(identifier: str) -> Tuple[str, str, int]:
def _resolve_target(hass: HomeAssistant, call: ServiceCall) -> Tuple[str, str, int]: def _resolve_target(hass: HomeAssistant, call: ServiceCall) -> Tuple[str, str, int]:
"""Resolve node/type/vmid from device target OR node+vmid (+ optional type).""" """Resolve node/type/vmid from device_id OR node+vmid (+ optional type)."""
device_id = _get_target_device_id(call) device_id = _get_device_id(call)
node = call.data.get(ATTR_NODE) node = call.data.get(ATTR_NODE)
vmid = call.data.get(ATTR_VMID) vmid = call.data.get(ATTR_VMID)
vmtype = call.data.get(ATTR_TYPE, "qemu") vmtype = call.data.get(ATTR_TYPE, "qemu")
@@ -95,7 +97,7 @@ def _resolve_target(hass: HomeAssistant, call: ServiceCall) -> Tuple[str, str, i
# manual mode # manual mode
if not node or vmid is None: if not node or vmid is None:
raise ValueError("Provide a Device target OR node + vmid (+ optional type/host/config_entry_id).") raise ValueError("Provide device_id OR node + vmid (+ optional type/host/config_entry_id).")
if vmtype not in VALID_TYPES: if vmtype not in VALID_TYPES:
raise ValueError(f"Invalid type: {vmtype} (allowed: {VALID_TYPES})") raise ValueError(f"Invalid type: {vmtype} (allowed: {VALID_TYPES})")
@@ -167,12 +169,12 @@ def _pick_entry_id_by_guest_lookup(hass: HomeAssistant, node: str, vmtype: str,
if not matches: if not matches:
raise ValueError( raise ValueError(
f"Could not find guest {node}/{vmtype}/{vmid} in any configured Proxmox host. " f"Could not find guest {node}/{vmtype}/{vmid} in any configured Proxmox host. "
"Provide host or config_entry_id, or use device target." "Provide host or config_entry_id, or use device_id."
) )
if len(matches) > 1: if len(matches) > 1:
raise ValueError( raise ValueError(
f"Guest {node}/{vmtype}/{vmid} exists on multiple configured hosts (ambiguous). " f"Guest {node}/{vmtype}/{vmid} exists on multiple configured hosts (ambiguous). "
"Please provide host or config_entry_id, or use device target." "Please provide host or config_entry_id, or use device_id."
) )
return matches[0] return matches[0]
@@ -181,24 +183,20 @@ def _resolve_entry_id(hass: HomeAssistant, call: ServiceCall, target: Tuple[str,
"""Resolve which config entry should execute this service call.""" """Resolve which config entry should execute this service call."""
domain_entries = _get_domain_entries(hass) domain_entries = _get_domain_entries(hass)
# 1) explicit config_entry_id
config_entry_id = call.data.get(ATTR_CONFIG_ENTRY_ID) config_entry_id = call.data.get(ATTR_CONFIG_ENTRY_ID)
if config_entry_id: if config_entry_id:
if config_entry_id not in domain_entries: if config_entry_id not in domain_entries:
raise ValueError(f"config_entry_id '{config_entry_id}' not found or not loaded.") raise ValueError(f"config_entry_id '{config_entry_id}' not found or not loaded.")
return config_entry_id return config_entry_id
# 2) by device target (best + unambiguous) device_id = _get_device_id(call)
device_id = _get_target_device_id(call)
if device_id: if device_id:
return _pick_entry_id_for_device(hass, device_id) return _pick_entry_id_for_device(hass, device_id)
# 3) by host
host = call.data.get(ATTR_HOST) host = call.data.get(ATTR_HOST)
if host: if host:
return _pick_entry_id_by_host(hass, host) return _pick_entry_id_by_host(hass, host)
# 4) last resort: guest lookup
node, vmtype, vmid = target node, vmtype, vmid = target
return _pick_entry_id_by_guest_lookup(hass, node, vmtype, vmid) return _pick_entry_id_by_guest_lookup(hass, node, vmtype, vmid)
@@ -218,30 +216,18 @@ async def async_register_services(hass: HomeAssistant) -> None:
raise ValueError(f"Selected config entry '{entry_id}' has no client (not loaded).") raise ValueError(f"Selected config entry '{entry_id}' has no client (not loaded).")
client = entry_data["client"] client = entry_data["client"]
_LOGGER.debug("Service action=%s entry=%s target=%s/%s/%s", action, entry_id, node, vmtype, vmid) _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) await client.guest_action(node=node, vmid=vmid, vmtype=vmtype, action=action)
async def handle_start(call: ServiceCall) -> None: hass.services.async_register(DOMAIN, SERVICE_START, lambda call: _call_action(call, "start"), schema=SERVICE_SCHEMA)
await _call_action(call, "start") hass.services.async_register(
DOMAIN, SERVICE_SHUTDOWN, lambda call: _call_action(call, "shutdown"), schema=SERVICE_SCHEMA
async def handle_shutdown(call: ServiceCall) -> None: )
await _call_action(call, "shutdown") hass.services.async_register(DOMAIN, SERVICE_STOP_HARD, lambda call: _call_action(call, "stop"), schema=SERVICE_SCHEMA)
hass.services.async_register(DOMAIN, SERVICE_REBOOT, lambda call: _call_action(call, "reboot"), schema=SERVICE_SCHEMA)
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: async def async_unregister_services(hass: HomeAssistant) -> None:
"""Unregister services (optional cleanup)."""
for svc in (SERVICE_START, SERVICE_SHUTDOWN, SERVICE_STOP_HARD, SERVICE_REBOOT): for svc in (SERVICE_START, SERVICE_SHUTDOWN, SERVICE_STOP_HARD, SERVICE_REBOOT):
if hass.services.has_service(DOMAIN, svc): if hass.services.has_service(DOMAIN, svc):
hass.services.async_remove(DOMAIN, svc) hass.services.async_remove(DOMAIN, svc)