mirror of
https://github.com/bahmcloud/easy_proxmox.git
synced 2026-04-06 10:51:14 +00:00
upload 0.6.0-alpha for hacs
This commit is contained in:
155
custom_components/proxmox_pve/__init__.py
Normal file
155
custom_components/proxmox_pve/__init__.py
Normal file
@@ -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
|
||||
64
custom_components/proxmox_pve/api.py
Normal file
64
custom_components/proxmox_pve/api.py
Normal file
@@ -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")
|
||||
200
custom_components/proxmox_pve/button.py
Normal file
200
custom_components/proxmox_pve/button.py
Normal file
@@ -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)
|
||||
108
custom_components/proxmox_pve/config_flow.py
Normal file
108
custom_components/proxmox_pve/config_flow.py
Normal file
@@ -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))
|
||||
27
custom_components/proxmox_pve/const.py
Normal file
27
custom_components/proxmox_pve/const.py
Normal file
@@ -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"]
|
||||
197
custom_components/proxmox_pve/coordinator.py
Normal file
197
custom_components/proxmox_pve/coordinator.py
Normal file
@@ -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
|
||||
BIN
custom_components/proxmox_pve/icon.png
Normal file
BIN
custom_components/proxmox_pve/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
BIN
custom_components/proxmox_pve/logo.png
Normal file
BIN
custom_components/proxmox_pve/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
11
custom_components/proxmox_pve/manifest.json
Normal file
11
custom_components/proxmox_pve/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
647
custom_components/proxmox_pve/sensor.py
Normal file
647
custom_components/proxmox_pve/sensor.py
Normal file
@@ -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])
|
||||
184
custom_components/proxmox_pve/switch.py
Normal file
184
custom_components/proxmox_pve/switch.py
Normal 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)
|
||||
32
custom_components/proxmox_pve/translations/en.json
Normal file
32
custom_components/proxmox_pve/translations/en.json
Normal file
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user