diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c97cde6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# Changelog + +## 0.6.0-alpha +- Extended Node monitoring with additional sensors: + - RAM Total (MB) and RAM Free (MB) + - Swap Used/Total/Free (MB) + - Node Storage (RootFS) Used/Total/Free in GB (3 decimals) +- Kept existing Node sensors: CPU (%), Load (1m), RAM Used (MB), Uptime (d/h/m) +- No changes to VM/CT entities, cleanup logic, or controls +- Add README.md and LICENSE + +## 0.5.2-alpha +- Options are now applied live without restart or integration reload + - Changing polling interval updates all coordinators immediately + - Changing IP preference mode/prefix updates all existing guest coordinators immediately +- Triggered refresh after saving options so sensors update quickly +- Renamed integration to "Easy Proxmox (by René Bachmann)" + +## 0.5.1-alpha +- Fixed Options Flow crash that caused: + “Config flow could not be loaded: 500 Internal Server Error” +- Fixed incompatibility with Home Assistant’s `OptionsFlow`: + - Removed illegal assignment to the read-only `config_entry` property + - Now fetching the ConfigEntry safely via `self.context["entry_id"]` +- Restored Options (gear icon) in the integration UI +- Options dialog can now be opened and saved without backend errors +- Improved compatibility with newer Home Assistant core versions +- Stabilized Config Flow import and initialization + + +## 0.5.0-alpha +- Added Options Flow: + - Configurable polling interval + - Configurable IP preference mode (prefer 192.168.*, private IPs, any, or custom prefix) +- Added Proxmox Node devices: + - One device per Proxmox node + - Sensors for: + - CPU usage (%) + - RAM used (MB) and total RAM (attribute) + - Uptime (days, hours, minutes) + - Load average (1 minute) +- VM/CT devices are now linked to their node device (via_device) +- Existing dynamic VM handling, rename detection and hard cleanup retained + +## 0.4.1 +- Fixed entity and device cleanup when a VM/CT is deleted: + - Entities are fully removed from Entity Registry + - Devices are fully removed from Device Registry + - No more “unavailable ghost entities” +- Guaranteed hard cleanup for removed guests + +## 0.4.0 +- Dynamic VM/CT discovery: + - New guests appear automatically without reload + - Removed guests are automatically cleaned up +- Live rename handling: + - Device names and entity names update when VM name changes +- Improved coordinator lifecycle handling + +## 0.3.0 +- One Home Assistant device per VM/CT +- Power Switch: + - ON → Start + - OFF → Shutdown (soft) +- Buttons: + - Reboot + - Stop (hard) +- Sensors per VM/CT: + - CPU usage (%) + - RAM usage (MB) + - Uptime (days, hours, minutes) + - Network In/Out (MB) + - Preferred IP address + list of all IPs +- VMID added to device and entity names +- Network and memory values converted to MB +- IP selection prioritizes LAN IPs (e.g. 192.168.*) + +## 0.2.0 +- Start, Stop and Reboot buttons added +- Domain renamed and integration structure stabilized +- Improved error handling and platform loading + +## 0.1.0 +- Initial Proxmox VE integration +- API token authentication +- Basic connectivity test via Config Flow +- First experimental entities diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..31095f8 --- /dev/null +++ b/__init__.py @@ -0,0 +1,155 @@ +import logging +from datetime import timedelta + +import aiohttp +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .api import ProxmoxClient +from .const import ( + DOMAIN, + PLATFORMS, + CONF_SCAN_INTERVAL, + CONF_IP_MODE, + CONF_IP_PREFIX, + DEFAULT_SCAN_INTERVAL, + DEFAULT_IP_MODE, + DEFAULT_IP_PREFIX, +) +from .coordinator import ProxmoxResourcesCoordinator, ProxmoxNodesCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def _opt(entry: ConfigEntry, key: str, default): + return entry.options.get(key, default) + + +async def _apply_options_now(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Apply updated options to all coordinators without reload/restart.""" + data = hass.data[DOMAIN][entry.entry_id] + + new_scan_interval = int(_opt(entry, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)) + new_ip_mode = str(_opt(entry, CONF_IP_MODE, DEFAULT_IP_MODE)) + new_ip_prefix = str(_opt(entry, CONF_IP_PREFIX, DEFAULT_IP_PREFIX)) + + # store new values for future coordinator creations + data["scan_interval"] = new_scan_interval + data["ip_mode"] = new_ip_mode + data["ip_prefix"] = new_ip_prefix + + td = timedelta(seconds=new_scan_interval) + + # cluster coordinators + resources = data.get("resources") + if resources: + resources.update_interval = td + + nodes = data.get("nodes") + if nodes: + nodes.update_interval = td + + # node coordinators + for node_coord in (data.get("node_coordinators") or {}).values(): + node_coord.update_interval = td + + # guest coordinators (also update ip preference config) + for guest_coord in (data.get("guest_coordinators") or {}).values(): + guest_coord.update_interval = td + guest_coord.ip_mode = new_ip_mode + guest_coord.ip_prefix = new_ip_prefix + + # trigger refresh so UI updates quickly + tasks = [] + if resources: + tasks.append(resources.async_request_refresh()) + if nodes: + tasks.append(nodes.async_request_refresh()) + + for node_coord in (data.get("node_coordinators") or {}).values(): + tasks.append(node_coord.async_request_refresh()) + + for guest_coord in (data.get("guest_coordinators") or {}).values(): + tasks.append(guest_coord.async_request_refresh()) + + if tasks: + # don't fail all if one refresh fails + for t in tasks: + hass.async_create_task(t) + + _LOGGER.debug( + "Applied options live for %s: scan_interval=%s ip_mode=%s ip_prefix=%s", + entry.entry_id, + new_scan_interval, + new_ip_mode, + new_ip_prefix, + ) + + +async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Called by HA when options are changed.""" + await _apply_options_now(hass, entry) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + session = aiohttp.ClientSession() + + client = ProxmoxClient( + host=entry.data["host"], + port=entry.data["port"], + token_name=entry.data["token_name"], + token_value=entry.data["token_value"], + verify_ssl=entry.data["verify_ssl"], + session=session, + ) + + scan_interval = int(_opt(entry, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)) + ip_mode = str(_opt(entry, CONF_IP_MODE, DEFAULT_IP_MODE)) + ip_prefix = str(_opt(entry, CONF_IP_PREFIX, DEFAULT_IP_PREFIX)) + + resources = ProxmoxResourcesCoordinator(hass, client, scan_interval=scan_interval) + nodes = ProxmoxNodesCoordinator(hass, client, scan_interval=scan_interval) + + await resources.async_config_entry_first_refresh() + await nodes.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + "session": session, + "client": client, + "resources": resources, + "nodes": nodes, + "scan_interval": scan_interval, + "ip_mode": ip_mode, + "ip_prefix": ip_prefix, + "guest_coordinators": {}, # (node, vmtype, vmid) -> ProxmoxGuestCoordinator + "node_coordinators": {}, # node -> ProxmoxNodeCoordinator + "platform_cache": {}, # per-platform caches + unsub handles + } + + # Apply option updates live (gear icon -> save) + entry.async_on_unload(entry.add_update_listener(_update_listener)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _LOGGER.debug("Set up proxmox_pve entry %s", entry.entry_id) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + data = hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) + if data: + pc = data.get("platform_cache") or {} + for key in ("sensor_unsub", "switch_unsub", "button_unsub"): + for unsub in pc.get(key, []): + try: + unsub() + except Exception: + pass + + if data.get("session"): + await data["session"].close() + + return unload_ok diff --git a/api.py b/api.py new file mode 100644 index 0000000..948db3e --- /dev/null +++ b/api.py @@ -0,0 +1,64 @@ +import asyncio +from dataclasses import dataclass +from typing import Any + +import aiohttp + + +class ProxmoxApiError(Exception): + """Raised for Proxmox API errors.""" + + +@dataclass +class ProxmoxClient: + host: str + port: int + token_name: str # "USER@REALM!TOKENID" + token_value: str + verify_ssl: bool + session: aiohttp.ClientSession + + @property + def base_url(self) -> str: + return f"https://{self.host}:{self.port}/api2/json" + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"PVEAPIToken={self.token_name}={self.token_value}"} + + async def _request(self, method: str, path: str, **kwargs) -> Any: + url = f"{self.base_url}{path}" + kwargs.setdefault("headers", {}) + kwargs["headers"].update(self._headers()) + + ssl = None if self.verify_ssl else False + + try: + async with self.session.request(method, url, ssl=ssl, **kwargs) as resp: + text = await resp.text() + if resp.status >= 400: + raise ProxmoxApiError(f"HTTP {resp.status} calling {path}: {text}") + payload = await resp.json() + return payload.get("data") + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + raise ProxmoxApiError(str(e)) from e + + async def test_connection(self) -> None: + await self._request("GET", "/version") + + async def list_cluster_resources(self) -> list[dict[str, Any]]: + return await self._request("GET", "/cluster/resources", params={"type": "vm"}) + + async def list_nodes(self) -> list[dict[str, Any]]: + return await self._request("GET", "/nodes") + + async def get_node_status(self, node: str) -> dict[str, Any]: + return await self._request("GET", f"/nodes/{node}/status") + + async def guest_action(self, node: str, vmid: int, vmtype: str, action: str) -> Any: + return await self._request("POST", f"/nodes/{node}/{vmtype}/{vmid}/status/{action}") + + async def get_guest_status_current(self, node: str, vmid: int, vmtype: str) -> dict[str, Any]: + return await self._request("GET", f"/nodes/{node}/{vmtype}/{vmid}/status/current") + + async def get_qemu_agent_network_ifaces(self, node: str, vmid: int) -> dict[str, Any]: + return await self._request("GET", f"/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces") diff --git a/button.py b/button.py new file mode 100644 index 0000000..e39ae9c --- /dev/null +++ b/button.py @@ -0,0 +1,200 @@ +import asyncio +import logging + +from homeassistant.components.button import ButtonEntity +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 _BaseGuestButton(CoordinatorEntity, ButtonEntity): + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator) + self._entry = entry + self._resource = dict(resource) + + def update_resource(self, resource: dict) -> None: + self._resource = dict(resource) + + @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")} + + +class ProxmoxGuestRebootButton(_BaseGuestButton): + _attr_icon = "mdi:restart" + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator, entry, resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_reboot" + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} Reboot" + + async def async_press(self) -> 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"], "reboot") + await self.coordinator.async_request_refresh() + + +class ProxmoxGuestHardStopButton(_BaseGuestButton): + _attr_icon = "mdi:stop-circle" + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator, entry, resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_stop_hard" + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} Stop (hard)" + + async def async_press(self) -> 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"], "stop") + 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], list[ButtonEntity]] = platform_cache.setdefault("button", {}) + + 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)) + for ent in cache[key]: + ent.update_resource(r) + ent.async_write_ha_state() + + # add + new_entities: list[ButtonEntity] = [] + for key, r in current.items(): + if key in cache: + continue + guest_coord = await _get_guest_coordinator(hass, entry, r) + ents = [ProxmoxGuestRebootButton(guest_coord, entry, r), ProxmoxGuestHardStopButton(guest_coord, entry, r)] + cache[key] = ents + new_entities.extend(ents) + + 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]}" + + for ent in cache[k]: + await ent.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("button_unsub", []).append(unsub) diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..b2ca693 --- /dev/null +++ b/config_flow.py @@ -0,0 +1,108 @@ +import aiohttp +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult + +from .api import ProxmoxClient, ProxmoxApiError +from .const import ( + DOMAIN, + CONF_HOST, + CONF_PORT, + CONF_VERIFY_SSL, + CONF_TOKEN_NAME, + CONF_TOKEN_VALUE, + DEFAULT_PORT, + DEFAULT_VERIFY_SSL, + # options + CONF_SCAN_INTERVAL, + CONF_IP_MODE, + CONF_IP_PREFIX, + DEFAULT_SCAN_INTERVAL, + DEFAULT_IP_MODE, + DEFAULT_IP_PREFIX, + IP_MODE_PREFER_192168, + IP_MODE_PREFER_PRIVATE, + IP_MODE_ANY, + IP_MODE_CUSTOM_PREFIX, +) + + +STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + vol.Required(CONF_TOKEN_NAME): str, + vol.Required(CONF_TOKEN_VALUE): str, + } +) + + +async def _validate_input(data: dict) -> None: + async with aiohttp.ClientSession() as session: + client = ProxmoxClient( + host=data[CONF_HOST], + port=int(data[CONF_PORT]), + token_name=data[CONF_TOKEN_NAME], + token_value=data[CONF_TOKEN_VALUE], + verify_ssl=bool(data[CONF_VERIFY_SSL]), + session=session, + ) + await client.test_connection() + + +def _options_schema(current: dict) -> vol.Schema: + ip_modes = [IP_MODE_PREFER_192168, IP_MODE_PREFER_PRIVATE, IP_MODE_ANY, IP_MODE_CUSTOM_PREFIX] + return vol.Schema( + { + vol.Required(CONF_SCAN_INTERVAL, default=int(current.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL))): vol.All( + vol.Coerce(int), vol.Range(min=5, max=3600) + ), + vol.Required(CONF_IP_MODE, default=current.get(CONF_IP_MODE, DEFAULT_IP_MODE)): vol.In(ip_modes), + vol.Required(CONF_IP_PREFIX, default=current.get(CONF_IP_PREFIX, DEFAULT_IP_PREFIX)): str, + } + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + + async def async_step_user(self, user_input=None) -> FlowResult: + errors = {} + + if user_input is not None: + unique = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}:{user_input[CONF_TOKEN_NAME]}" + await self.async_set_unique_id(unique) + self._abort_if_unique_id_configured() + + try: + await _validate_input(user_input) + except ProxmoxApiError: + errors["base"] = "cannot_connect" + except Exception: + errors["base"] = "unknown" + else: + title = f"Proxmox {user_input[CONF_HOST]}" + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form(step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors) + + @staticmethod + def async_get_options_flow(config_entry: config_entries.ConfigEntry): + # IMPORTANT: Do not store config_entry on OptionsFlowHandler as attribute named config_entry. + return OptionsFlowHandler() + + +class OptionsFlowHandler(config_entries.OptionsFlow): + async def async_step_init(self, user_input=None) -> FlowResult: + # Fetch the config entry safely (works across HA versions) + entry_id = self.context.get("entry_id") + config_entry = self.hass.config_entries.async_get_entry(entry_id) if entry_id else None + + current = dict(config_entry.options) if config_entry else {} + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form(step_id="init", data_schema=_options_schema(current)) diff --git a/const.py b/const.py new file mode 100644 index 0000000..e95cde7 --- /dev/null +++ b/const.py @@ -0,0 +1,27 @@ +DOMAIN = "proxmox_pve" + +CONF_HOST = "host" +CONF_PORT = "port" +CONF_VERIFY_SSL = "verify_ssl" +CONF_TOKEN_NAME = "token_name" +CONF_TOKEN_VALUE = "token_value" + +# Options +CONF_SCAN_INTERVAL = "scan_interval" +CONF_IP_MODE = "ip_mode" +CONF_IP_PREFIX = "ip_prefix" + +DEFAULT_PORT = 8006 +DEFAULT_VERIFY_SSL = True + +DEFAULT_SCAN_INTERVAL = 20 + +IP_MODE_PREFER_192168 = "prefer_192168" +IP_MODE_PREFER_PRIVATE = "prefer_private" +IP_MODE_ANY = "any" +IP_MODE_CUSTOM_PREFIX = "custom_prefix" + +DEFAULT_IP_MODE = IP_MODE_PREFER_192168 +DEFAULT_IP_PREFIX = "192.168." + +PLATFORMS = ["sensor", "switch", "button"] diff --git a/coordinator.py b/coordinator.py new file mode 100644 index 0000000..db38294 --- /dev/null +++ b/coordinator.py @@ -0,0 +1,197 @@ +import logging +from datetime import timedelta +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import ProxmoxApiError, ProxmoxClient +from .const import ( + DEFAULT_SCAN_INTERVAL, + IP_MODE_ANY, + IP_MODE_CUSTOM_PREFIX, + IP_MODE_PREFER_192168, + IP_MODE_PREFER_PRIVATE, +) + +_LOGGER = logging.getLogger(__name__) + + +def _is_private_ipv4(addr: str) -> bool: + if addr.startswith("10."): + return True + if addr.startswith("192.168."): + return True + if addr.startswith("172."): + try: + second = int(addr.split(".")[1]) + return 16 <= second <= 31 + except Exception: + return False + return False + + +def _pick_preferred_ip(ips: list[str], mode: str, prefix: str | None) -> str | None: + if not ips: + return None + + # normalize + prefix = (prefix or "").strip() + + if mode == IP_MODE_CUSTOM_PREFIX and prefix: + for ip in ips: + if ip.startswith(prefix): + return ip + + if mode == IP_MODE_PREFER_192168: + for ip in ips: + if ip.startswith("192.168."): + return ip + for ip in ips: + if _is_private_ipv4(ip): + return ip + for ip in ips: + if "." in ip: + return ip + return ips[0] + + if mode == IP_MODE_PREFER_PRIVATE: + for ip in ips: + if _is_private_ipv4(ip): + return ip + for ip in ips: + if "." in ip: + return ip + return ips[0] + + # IP_MODE_ANY (or fallback) + for ip in ips: + if "." in ip: + return ip + return ips[0] + + +class ProxmoxResourcesCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): + """Coordinator for /cluster/resources?type=vm""" + + def __init__(self, hass: HomeAssistant, client: ProxmoxClient, scan_interval: int = DEFAULT_SCAN_INTERVAL) -> None: + self.client = client + super().__init__( + hass=hass, + logger=_LOGGER, + name="proxmox_pve_resources", + update_method=self._async_update_data, + update_interval=timedelta(seconds=scan_interval), + ) + + async def _async_update_data(self) -> list[dict[str, Any]]: + try: + return await self.client.list_cluster_resources() + except ProxmoxApiError as err: + raise UpdateFailed(str(err)) from err + + +class ProxmoxNodesCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): + """Coordinator for /nodes""" + + def __init__(self, hass: HomeAssistant, client: ProxmoxClient, scan_interval: int = DEFAULT_SCAN_INTERVAL) -> None: + self.client = client + super().__init__( + hass=hass, + logger=_LOGGER, + name="proxmox_pve_nodes", + update_method=self._async_update_data, + update_interval=timedelta(seconds=scan_interval), + ) + + async def _async_update_data(self) -> list[dict[str, Any]]: + try: + return await self.client.list_nodes() + except ProxmoxApiError as err: + raise UpdateFailed(str(err)) from err + + +class ProxmoxNodeCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator per node: /nodes/{node}/status""" + + def __init__( + self, + hass: HomeAssistant, + client: ProxmoxClient, + node: str, + scan_interval: int = DEFAULT_SCAN_INTERVAL, + ) -> None: + self.client = client + self.node = node + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"proxmox_pve_node_{node}", + update_method=self._async_update_data, + update_interval=timedelta(seconds=scan_interval), + ) + + async def _async_update_data(self) -> dict[str, Any]: + try: + return await self.client.get_node_status(self.node) + except ProxmoxApiError as err: + raise UpdateFailed(str(err)) from err + + +class ProxmoxGuestCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator per guest: /status/current (+ best-effort IPs).""" + + def __init__( + self, + hass: HomeAssistant, + client: ProxmoxClient, + node: str, + vmid: int, + vmtype: str, + scan_interval: int = DEFAULT_SCAN_INTERVAL, + ip_mode: str = IP_MODE_PREFER_192168, + ip_prefix: str | None = None, + ) -> None: + self.client = client + self.node = node + self.vmid = vmid + self.vmtype = vmtype + self.ip_mode = ip_mode + self.ip_prefix = ip_prefix + + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"proxmox_pve_guest_{node}_{vmtype}_{vmid}", + update_method=self._async_update_data, + update_interval=timedelta(seconds=scan_interval), + ) + + async def _async_update_data(self) -> dict[str, Any]: + try: + status = await self.client.get_guest_status_current(self.node, self.vmid, self.vmtype) + except ProxmoxApiError as err: + raise UpdateFailed(str(err)) from err + + ip_list: list[str] = [] + + if self.vmtype == "qemu": + try: + agent = await self.client.get_qemu_agent_network_ifaces(self.node, self.vmid) + for iface in agent.get("result", []): + for ip in iface.get("ip-addresses", []): + addr = ip.get("ip-address") + if not addr: + continue + if addr.startswith("127.") or addr.startswith("fe80:") or addr == "::1": + continue + ip_list.append(addr) + except ProxmoxApiError: + pass + except Exception: + pass + + ip_list = sorted(set(ip_list)) + status["_ip_addresses"] = ip_list + status["_preferred_ip"] = _pick_preferred_ip(ip_list, self.ip_mode, self.ip_prefix) + return status diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..de0a95d Binary files /dev/null and b/icon.png differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..98deb83 Binary files /dev/null and b/logo.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2bea100 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "proxmox_pve", + "name": "Easy Proxmox (by René Bachmann)", + "version": "0.6.0-alpha", + "documentation": "https://example.invalid", + "requirements": ["aiohttp>=3.9.0"], + "codeowners": ["@BAHMCLOUD", "@ReneBachmann"], + "config_flow": true, + "iot_class": "local_polling", + "integration_type": "service" +} diff --git a/sensor.py b/sensor.py new file mode 100644 index 0000000..a268b67 --- /dev/null +++ b/sensor.py @@ -0,0 +1,647 @@ +import asyncio +import logging +from typing import Any + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfInformation +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, ProxmoxNodeCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def _bytes_to_mb(value: int | float) -> float: + return round(float(value) / (1024.0 * 1024.0), 2) + + +def _bytes_to_gb_3(value: int | float) -> float: + return round(float(value) / (1024.0 * 1024.0 * 1024.0), 3) + + +def _format_uptime(seconds: int) -> str: + if seconds < 0: + seconds = 0 + days = seconds // 86400 + rem = seconds % 86400 + hours = rem // 3600 + minutes = (rem % 3600) // 60 + return f"{days}d {hours}h {minutes:02d}m" + + +# ----------------------- +# Node helpers +# ----------------------- +def _node_id(node: str) -> str: + return f"node:{node}" + + +def _node_name(node: str) -> str: + return f"Proxmox Node {node}" + + +async def _get_node_coordinator(hass: HomeAssistant, entry: ConfigEntry, node: str) -> ProxmoxNodeCoordinator: + data = hass.data[DOMAIN][entry.entry_id] + if node in data["node_coordinators"]: + return data["node_coordinators"][node] + + coord = ProxmoxNodeCoordinator( + hass=hass, + client=data["client"], + node=node, + scan_interval=int(data["scan_interval"]), + ) + data["node_coordinators"][node] = coord + hass.async_create_task(coord.async_config_entry_first_refresh()) + return coord + + +# ----------------------- +# Guest helpers +# ----------------------- +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, device_ident: str) -> None: + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_ident)}) + if device: + dev_reg.async_remove_device(device.id) + + +# ----------------------- +# Node Entities +# ----------------------- +class ProxmoxNodeBase(CoordinatorEntity): + def __init__(self, coordinator: ProxmoxNodeCoordinator, entry: ConfigEntry, node: str) -> None: + super().__init__(coordinator) + self._entry = entry + self._node = node + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, _node_id(self._node))}, + "name": _node_name(self._node), + "manufacturer": "Proxmox VE", + "model": "Node", + } + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return {"node": self._node} + + +class ProxmoxNodeCpuSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:cpu-64-bit" + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_cpu" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} CPU" + + @property + def native_value(self) -> float | None: + cpu = (self.coordinator.data or {}).get("cpu") + return None if cpu is None else round(float(cpu) * 100.0, 2) + + +class ProxmoxNodeUptimeSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:timer-outline" + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_uptime" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} Uptime" + + @property + def native_value(self) -> str | None: + up = (self.coordinator.data or {}).get("uptime") + return None if up is None else _format_uptime(int(up)) + + +class ProxmoxNodeLoad1Sensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:gauge" + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_load1" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} Load (1m)" + + @property + def native_value(self) -> float | None: + la = (self.coordinator.data or {}).get("loadavg") + if not la: + return None + try: + if isinstance(la, list) and la: + return float(la[0]) + if isinstance(la, str): + return float(la.split()[0]) + except Exception: + return None + return None + + +# ---- RAM (MB) +class ProxmoxNodeRamUsedSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:memory" + _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_ram_used_mb" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} RAM Used" + + @property + def native_value(self) -> float | None: + mem = (self.coordinator.data or {}).get("memory", {}).get("used") + return None if mem is None else _bytes_to_mb(int(mem)) + + +class ProxmoxNodeRamTotalSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:memory" + _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_ram_total_mb" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} RAM Total" + + @property + def native_value(self) -> float | None: + total = (self.coordinator.data or {}).get("memory", {}).get("total") + return None if total is None else _bytes_to_mb(int(total)) + + +class ProxmoxNodeRamFreeSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:memory" + _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_ram_free_mb" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} RAM Free" + + @property + def native_value(self) -> float | None: + free = (self.coordinator.data or {}).get("memory", {}).get("free") + return None if free is None else _bytes_to_mb(int(free)) + + +# ---- Swap (MB) +class ProxmoxNodeSwapUsedSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:swap-horizontal" + _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_swap_used_mb" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} Swap Used" + + @property + def native_value(self) -> float | None: + used = (self.coordinator.data or {}).get("swap", {}).get("used") + return None if used is None else _bytes_to_mb(int(used)) + + +class ProxmoxNodeSwapTotalSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:swap-horizontal" + _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_swap_total_mb" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} Swap Total" + + @property + def native_value(self) -> float | None: + total = (self.coordinator.data or {}).get("swap", {}).get("total") + return None if total is None else _bytes_to_mb(int(total)) + + +class ProxmoxNodeSwapFreeSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:swap-horizontal" + _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_swap_free_mb" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} Swap Free" + + @property + def native_value(self) -> float | None: + free = (self.coordinator.data or {}).get("swap", {}).get("free") + return None if free is None else _bytes_to_mb(int(free)) + + +# ---- RootFS / Node Storage (GB, 3 decimals) +class ProxmoxNodeStorageUsedSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:harddisk" + _attr_native_unit_of_measurement = "GB" + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_storage_used_gb" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} Storage Used" + + @property + def native_value(self) -> float | None: + used = (self.coordinator.data or {}).get("rootfs", {}).get("used") + return None if used is None else _bytes_to_gb_3(int(used)) + + +class ProxmoxNodeStorageTotalSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:harddisk" + _attr_native_unit_of_measurement = "GB" + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_storage_total_gb" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} Storage Total" + + @property + def native_value(self) -> float | None: + total = (self.coordinator.data or {}).get("rootfs", {}).get("total") + return None if total is None else _bytes_to_gb_3(int(total)) + + +class ProxmoxNodeStorageFreeSensor(ProxmoxNodeBase, SensorEntity): + _attr_icon = "mdi:harddisk" + _attr_native_unit_of_measurement = "GB" + + def __init__(self, coordinator, entry, node: str) -> None: + super().__init__(coordinator, entry, node) + self._attr_unique_id = f"{entry.entry_id}_{_node_id(node)}_storage_free_gb" + + @property + def name(self) -> str: + return f"{_node_name(self._node)} Storage Free" + + @property + def native_value(self) -> float | None: + free = (self.coordinator.data or {}).get("rootfs", {}).get("free") + return None if free is None else _bytes_to_gb_3(int(free)) + + +# ----------------------- +# Guest Entities +# ----------------------- +class ProxmoxBaseGuestEntity(CoordinatorEntity): + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator) + self._entry = entry + self._resource = dict(resource) + + def update_resource(self, resource: dict) -> None: + self._resource = dict(resource) + + @property + def device_info(self): + node = self._resource.get("node") + via = (DOMAIN, _node_id(node)) if node else None + + info = { + "identifiers": {(DOMAIN, _guest_id(self._resource))}, + "name": _guest_display_name(self._resource), + "manufacturer": "Proxmox VE", + "model": _model_for(self._resource), + } + if via: + info["via_device"] = via + return info + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return {"vmid": self._resource.get("vmid"), "node": self._resource.get("node"), "type": self._resource.get("type")} + + +class ProxmoxGuestStatusSensor(ProxmoxBaseGuestEntity, SensorEntity): + _attr_icon = "mdi:power" + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator, entry, resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_status" + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} Status" + + @property + def native_value(self) -> str | None: + return (self.coordinator.data or {}).get("status") + + +class ProxmoxGuestCpuSensor(ProxmoxBaseGuestEntity, SensorEntity): + _attr_icon = "mdi:cpu-64-bit" + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator, entry, resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_cpu" + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} CPU" + + @property + def native_value(self) -> float | None: + cpu = (self.coordinator.data or {}).get("cpu") + return None if cpu is None else round(float(cpu) * 100.0, 2) + + +class ProxmoxGuestRamUsedMB(ProxmoxBaseGuestEntity, SensorEntity): + _attr_icon = "mdi:memory" + _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator, entry, resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_ram_used_mb" + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} RAM Used" + + @property + def native_value(self) -> float | None: + mem = (self.coordinator.data or {}).get("mem") + return None if mem is None else _bytes_to_mb(int(mem)) + + +class ProxmoxGuestUptimePretty(ProxmoxBaseGuestEntity, SensorEntity): + _attr_icon = "mdi:timer-outline" + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator, entry, resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_uptime_pretty" + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} Uptime" + + @property + def native_value(self) -> str | None: + uptime = (self.coordinator.data or {}).get("uptime") + return None if uptime is None else _format_uptime(int(uptime)) + + +class ProxmoxGuestNetInMB(ProxmoxBaseGuestEntity, SensorEntity): + _attr_icon = "mdi:download-network" + _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator, entry, resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_netin_mb" + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} Network In" + + @property + def native_value(self) -> float | None: + v = (self.coordinator.data or {}).get("netin") + return None if v is None else _bytes_to_mb(int(v)) + + +class ProxmoxGuestNetOutMB(ProxmoxBaseGuestEntity, SensorEntity): + _attr_icon = "mdi:upload-network" + _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator, entry, resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_netout_mb" + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} Network Out" + + @property + def native_value(self) -> float | None: + v = (self.coordinator.data or {}).get("netout") + return None if v is None else _bytes_to_mb(int(v)) + + +class ProxmoxGuestPreferredIP(ProxmoxBaseGuestEntity, SensorEntity): + _attr_icon = "mdi:ip-network" + + def __init__(self, coordinator, entry: ConfigEntry, resource: dict) -> None: + super().__init__(coordinator, entry, resource) + self._attr_unique_id = f"{entry.entry_id}_{_guest_id(resource)}_ip_preferred" + + @property + def name(self) -> str: + return f"{_guest_display_name(self._resource)} IP" + + @property + def native_value(self) -> str | None: + return (self.coordinator.data or {}).get("_preferred_ip") + + @property + def extra_state_attributes(self) -> dict[str, Any]: + attrs = super().extra_state_attributes + attrs["ip_addresses"] = (self.coordinator.data or {}).get("_ip_addresses", []) + return attrs + + +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"] + nodes_coord = data["nodes"] + + platform_cache = data.setdefault("platform_cache", {}) + guest_cache: dict[tuple[str, str, int], list[SensorEntity]] = platform_cache.setdefault("sensor_guest", {}) + node_cache: dict[str, list[SensorEntity]] = platform_cache.setdefault("sensor_node", {}) + + async def _sync_nodes() -> None: + nodes = nodes_coord.data or [] + current_nodes = {n.get("node") for n in nodes if n.get("node")} + + new_entities: list[SensorEntity] = [] + for node in sorted(current_nodes): + if node in node_cache: + continue + + node_c = await _get_node_coordinator(hass, entry, node) + ents = [ + ProxmoxNodeCpuSensor(node_c, entry, node), + ProxmoxNodeLoad1Sensor(node_c, entry, node), + ProxmoxNodeRamUsedSensor(node_c, entry, node), + ProxmoxNodeRamTotalSensor(node_c, entry, node), + ProxmoxNodeRamFreeSensor(node_c, entry, node), + ProxmoxNodeSwapUsedSensor(node_c, entry, node), + ProxmoxNodeSwapTotalSensor(node_c, entry, node), + ProxmoxNodeSwapFreeSensor(node_c, entry, node), + ProxmoxNodeStorageUsedSensor(node_c, entry, node), + ProxmoxNodeStorageTotalSensor(node_c, entry, node), + ProxmoxNodeStorageFreeSensor(node_c, entry, node), + ProxmoxNodeUptimeSensor(node_c, entry, node), + ] + node_cache[node] = ents + new_entities.extend(ents) + + if new_entities: + async_add_entities(new_entities, update_before_add=False) + + removed = [n for n in list(node_cache.keys()) if n not in current_nodes] + for n in removed: + for ent in node_cache[n]: + await ent.async_remove() + del node_cache[n] + await _remove_device(hass, _node_id(n)) + + async def _sync_guests() -> 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 + + for key, r in current.items(): + if key not in guest_cache: + continue + gid = _guest_id(r) + _update_device_name(hass, gid, _guest_display_name(r), _model_for(r)) + for ent in guest_cache[key]: + ent.update_resource(r) + ent.async_write_ha_state() + + new_entities: list[SensorEntity] = [] + for key, r in current.items(): + if key in guest_cache: + continue + guest_coord = await _get_guest_coordinator(hass, entry, r) + ents = [ + ProxmoxGuestStatusSensor(guest_coord, entry, r), + ProxmoxGuestCpuSensor(guest_coord, entry, r), + ProxmoxGuestRamUsedMB(guest_coord, entry, r), + ProxmoxGuestUptimePretty(guest_coord, entry, r), + ProxmoxGuestNetInMB(guest_coord, entry, r), + ProxmoxGuestNetOutMB(guest_coord, entry, r), + ProxmoxGuestPreferredIP(guest_coord, entry, r), + ] + guest_cache[key] = ents + new_entities.extend(ents) + + if new_entities: + async_add_entities(new_entities, update_before_add=False) + + removed = [k for k in list(guest_cache.keys()) if k not in current] + for k in removed: + gid = f"{k[0]}:{k[1]}:{k[2]}" + for ent in guest_cache[k]: + await ent.async_remove() + del guest_cache[k] + + data["guest_coordinators"].pop(k, None) + await _purge_guest_entity_registry(hass, entry, gid) + await _remove_device(hass, gid) + + await _sync_nodes() + await _sync_guests() + + unsub_nodes = nodes_coord.async_add_listener(lambda: hass.async_create_task(_sync_nodes())) + unsub_guests = resources_coord.async_add_listener(lambda: hass.async_create_task(_sync_guests())) + platform_cache.setdefault("sensor_unsub", []).extend([unsub_nodes, unsub_guests]) diff --git a/switch.py b/switch.py new file mode 100644 index 0000000..0af53da --- /dev/null +++ b/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) diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..912dd0b --- /dev/null +++ b/translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect to Proxmox API.", + "unknown": "Unknown error." + }, + "step": { + "user": { + "title": "Connect to Proxmox VE", + "data": { + "host": "Host", + "port": "Port", + "verify_ssl": "Verify SSL certificate", + "token_name": "Token name (USER@REALM!TOKENID)", + "token_value": "Token secret" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Proxmox VE Options", + "data": { + "scan_interval": "Polling interval (seconds)", + "ip_mode": "Preferred IP mode", + "ip_prefix": "Custom IP prefix (only for custom mode)" + } + } + } + } +}