mirror of
https://github.com/bahmcloud/easy_proxmox.git
synced 2026-04-06 10:51:14 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ceb5353ae9 | |||
| 64b95c0f17 | |||
| 24da6a9c31 | |||
| c7c12997ed | |||
| 4565bb3809 | |||
| 2b225ac15a | |||
| eec020f0e6 | |||
| 44b0779797 | |||
| 8b8a651979 | |||
| 084b9d28bb | |||
| 7b09ed70f4 | |||
| 68b4ef5534 | |||
| a5807e1e7a | |||
| f63e548b8b | |||
| ef47ce134a |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.7.2
|
||||||
|
- Fixed service validation for device targets:
|
||||||
|
- Home Assistant may pass `device_id` as a list (target/data wrapper)
|
||||||
|
- Services now accept both `str` and `list[str]` for `device_id`
|
||||||
|
- Improved device target parsing for UI and script wrappers
|
||||||
|
|
||||||
|
## 0.7.1
|
||||||
|
- Fixed Home Assistant service UI integration:
|
||||||
|
- Services now properly expose the **Device selector** in the visual automation editor
|
||||||
|
- Implemented correct handling of `call.target.device_id`
|
||||||
|
- Services are now fully compatible with Home Assistant’s target system
|
||||||
|
- Fixed issue where only a YAML data field was shown instead of a device selection field
|
||||||
|
- Improved service robustness when used in multi-host environments
|
||||||
|
- This release is a bugfix release for v0.7.0
|
||||||
|
|
||||||
|
## 0.7.0
|
||||||
|
- Added full service support for automations and scripts:
|
||||||
|
- `proxmox_pve.start`
|
||||||
|
- `proxmox_pve.shutdown`
|
||||||
|
- `proxmox_pve.stop_hard`
|
||||||
|
- `proxmox_pve.reboot`
|
||||||
|
- Services are now fully multi-host capable:
|
||||||
|
- Automatic detection of the correct Proxmox host when using `device_id`
|
||||||
|
- Optional selection via `config_entry_id`
|
||||||
|
- Optional selection via `host`
|
||||||
|
- Automatic lookup by `node/vmid/type` if no host is given
|
||||||
|
- Clear error handling for ambiguous multi-host targets
|
||||||
|
- Services can be used in:
|
||||||
|
- Automations
|
||||||
|
- Scripts
|
||||||
|
- Dashboards
|
||||||
|
- Added `services.yaml` for proper UI descriptions in Home Assistant
|
||||||
|
- Easy Proxmox can now be fully controlled without any buttons or switches, purely via automations
|
||||||
|
|
||||||
## 0.6.1
|
## 0.6.1
|
||||||
- Fix correct autor in hacs
|
- Fix correct autor in hacs
|
||||||
|
|
||||||
@@ -92,3 +126,5 @@
|
|||||||
- Basic connectivity test via Config Flow
|
- Basic connectivity test via Config Flow
|
||||||
- First experimental entities
|
- First experimental entities
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
158
README.md
158
README.md
@@ -1,3 +1,11 @@
|
|||||||
|
# Easy Proxmox (by René Bachmann)
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=bahmcloud&repository=easy_proxmox)
|
||||||
|
|
||||||
|
|
||||||
<img width="1230" height="300" alt="logo" src="https://github.com/user-attachments/assets/7649cc04-bdcd-449e-bf83-c4f980f3de74" />
|
<img width="1230" height="300" alt="logo" src="https://github.com/user-attachments/assets/7649cc04-bdcd-449e-bf83-c4f980f3de74" />
|
||||||
|
|
||||||
A powerful Home Assistant integration to monitor and control Proxmox VE. With Easy Proxmox you can monitor nodes, VMs and containers directly in Home Assistant, start/stop/reboot guests and display detailed system metrics.
|
A powerful Home Assistant integration to monitor and control Proxmox VE. With Easy Proxmox you can monitor nodes, VMs and containers directly in Home Assistant, start/stop/reboot guests and display detailed system metrics.
|
||||||
@@ -35,20 +43,30 @@ A powerful Home Assistant integration to monitor and control Proxmox VE. With Ea
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## 📦 Installation via HACS (Recommended)
|
||||||
|
|
||||||
1. Create the folder:
|
To install Easy Proxmox through HACS:
|
||||||
/config/custom_components/proxmox_pve/
|
|
||||||
|
|
||||||
|
1. **Ensure HACS is already installed** in your Home Assistant instance.
|
||||||
|
If not, follow the official guide: https://hacs.xyz/
|
||||||
|
|
||||||
2. Copy all integration files into that folder.
|
2. Open **HACS → Integrations**.
|
||||||
|
|
||||||
3. Restart Home Assistant.
|
3. Click the **“⋯” (three dots)** → **Custom repositories**.
|
||||||
|
|
||||||
4. Add the integration:
|
4. Add the following repository:
|
||||||
|
- Repository URL:
|
||||||
|
```
|
||||||
|
https://github.com/bahmcloud/easy_proxmox
|
||||||
|
```
|
||||||
|
- Category: **Integration**
|
||||||
|
- Version: **Tags (recommended)**
|
||||||
|
|
||||||
Settings → Devices & Services → Add Integration → Easy Proxmox
|
5. Click **Add**, then locate **Easy Proxmox** in the HACS list and click **Install**.
|
||||||
|
|
||||||
|
6. After installation, **restart Home Assistant**.
|
||||||
|
|
||||||
|
7. Go to **Settings → Devices & Services → Add Integration → Easy Proxmox**.
|
||||||
---
|
---
|
||||||
|
|
||||||
## Proxmox: Create User & API Token
|
## Proxmox: Create User & API Token
|
||||||
@@ -64,8 +82,6 @@ Example:
|
|||||||
- Realm: `pam` or `pve`
|
- Realm: `pam` or `pve`
|
||||||
- Set a password (only for management; the token will be used for API access)
|
- Set a password (only for management; the token will be used for API access)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2) Create an API Token
|
### 2) Create an API Token
|
||||||
|
|
||||||
Datacenter → Permissions → API Tokens → Add
|
Datacenter → Permissions → API Tokens → Add
|
||||||
@@ -81,9 +97,6 @@ You will get:
|
|||||||
Token Name: homeassistant@pve!easyproxmox
|
Token Name: homeassistant@pve!easyproxmox
|
||||||
Token Secret: <long secret string>
|
Token Secret: <long secret string>
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3) Assign Permissions (Admin Rights)
|
### 3) Assign Permissions (Admin Rights)
|
||||||
|
|
||||||
To ensure full functionality (monitoring + guest controls), assign admin rights:
|
To ensure full functionality (monitoring + guest controls), assign admin rights:
|
||||||
@@ -121,12 +134,117 @@ After saving:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🚀 Services & Automations (since v0.7.0)
|
||||||
|
|
||||||
|
Easy Proxmox provides Home Assistant services so you can fully control your VMs and containers in automations and scripts without using buttons or switches.
|
||||||
|
|
||||||
|
Available services:
|
||||||
|
|
||||||
|
| Service | Description |
|
||||||
|
|--------|------------|
|
||||||
|
| `proxmox_pve.start` | Start a VM or container |
|
||||||
|
| `proxmox_pve.shutdown` | Gracefully shutdown a VM or container |
|
||||||
|
| `proxmox_pve.stop_hard` | Hard stop a VM or container |
|
||||||
|
| `proxmox_pve.reboot` | Reboot a VM or container |
|
||||||
|
|
||||||
|
All services are **multi-host aware** and automatically select the correct Proxmox server.
|
||||||
|
|
||||||
|
### ✅ Recommended usage: Device based
|
||||||
|
|
||||||
|
This is the safest and easiest way, especially for multi-host setups.
|
||||||
|
|
||||||
|
In an automation or script:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: proxmox_pve.shutdown
|
||||||
|
target:
|
||||||
|
device_id: YOUR_DEVICE_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
In the UI, you can simply select the VM/CT device from the dropdown.
|
||||||
|
|
||||||
|
Home Assistant will automatically:
|
||||||
|
|
||||||
|
- Find the correct Proxmox host
|
||||||
|
- Find the correct node
|
||||||
|
- Execute the action
|
||||||
|
|
||||||
|
### 🔧 Manual usage: Node / VMID based
|
||||||
|
|
||||||
|
You can also call services manually:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: proxmox_pve.reboot
|
||||||
|
data:
|
||||||
|
node: pve1
|
||||||
|
vmid: 100
|
||||||
|
type: qemu
|
||||||
|
```
|
||||||
|
If you have multiple Proxmox servers configured, you should also specify one of:
|
||||||
|
```yaml
|
||||||
|
host: 192.168.178.101
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
```yaml
|
||||||
|
config_entry_id: 8d9f2e7b1c3d4a5f...
|
||||||
|
```
|
||||||
|
|
||||||
|
This avoids ambiguity.
|
||||||
|
|
||||||
|
### 🧠 Resolution priority
|
||||||
|
|
||||||
|
When a service is called, Easy Proxmox resolves the target in this order:
|
||||||
|
|
||||||
|
1. `config_entry_id`
|
||||||
|
2. `device_id`
|
||||||
|
3. `host`
|
||||||
|
4. Guest lookup by `node + vmid + type`
|
||||||
|
|
||||||
|
If a guest exists on multiple Proxmox hosts, the call fails and asks for clarification.
|
||||||
|
|
||||||
|
### 🏗 Example automations
|
||||||
|
|
||||||
|
Shutdown all test systems at night:
|
||||||
|
```yaml
|
||||||
|
alias: Stop Test VM at Night
|
||||||
|
trigger:
|
||||||
|
- platform: time
|
||||||
|
at: "23:00:00"
|
||||||
|
action:
|
||||||
|
- service: proxmox_pve.shutdown
|
||||||
|
target:
|
||||||
|
device_id: 123456abcdef...
|
||||||
|
```
|
||||||
|
|
||||||
|
Start a VM when electricity price is low:
|
||||||
|
```yaml
|
||||||
|
alias: Start VM on cheap power
|
||||||
|
trigger:
|
||||||
|
- platform: numeric_state
|
||||||
|
entity_id: sensor.power_price
|
||||||
|
below: 0.20
|
||||||
|
action:
|
||||||
|
- service: proxmox_pve.start
|
||||||
|
target:
|
||||||
|
device_id: 123456abcdef...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🧩 Why this matters
|
||||||
|
|
||||||
|
With services you can:
|
||||||
|
|
||||||
|
- Fully automate your Proxmox infrastructure
|
||||||
|
- Remove dependency on dashboard buttons
|
||||||
|
- Build power-saving or maintenance automations
|
||||||
|
- Control multiple Proxmox clusters cleanly
|
||||||
|
|
||||||
|
---
|
||||||
## Options (Gear Icon)
|
## Options (Gear Icon)
|
||||||
|
|
||||||
After setup, open:
|
After setup, open:
|
||||||
Settings → Devices & Services → Easy Proxmox → Options (gear icon)
|
Settings → Devices & Services → Easy Proxmox → Options (gear icon)
|
||||||
|
|
||||||
|
|
||||||
### Polling Interval
|
### Polling Interval
|
||||||
How often data is fetched from Proxmox.
|
How often data is fetched from Proxmox.
|
||||||
|
|
||||||
@@ -138,8 +256,6 @@ How often data is fetched from Proxmox.
|
|||||||
|
|
||||||
Changes are applied immediately (no restart required).
|
Changes are applied immediately (no restart required).
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### IP Preference Mode
|
### IP Preference Mode
|
||||||
|
|
||||||
Controls which IP is shown as the “primary” IP for a guest:
|
Controls which IP is shown as the “primary” IP for a guest:
|
||||||
@@ -151,8 +267,6 @@ Controls which IP is shown as the “primary” IP for a guest:
|
|||||||
| any | Use the first available IP |
|
| any | Use the first available IP |
|
||||||
| custom_prefix | Use a custom prefix |
|
| custom_prefix | Use a custom prefix |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Custom IP Prefix
|
### Custom IP Prefix
|
||||||
|
|
||||||
Only relevant if `custom_prefix` is selected.
|
Only relevant if `custom_prefix` is selected.
|
||||||
@@ -217,10 +331,16 @@ The API token has admin rights. Treat it like a root password:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License / Support
|
## Support & Contributing
|
||||||
|
|
||||||
Look into LICENSE file.
|
If you need help, open an issue in GitHub.
|
||||||
|
Want to contribute? Feel free to submit PRs!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Easy Proxmox aims to provide a complete Proxmox VE experience in Home Assistant.**
|
**Easy Proxmox aims to provide a complete Proxmox VE experience in Home Assistant.**
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md) for full version history.
|
||||||
|
|
||||||
|
Releases: https://github.com/bahmcloud/easy_proxmox/releases
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .const import (
|
|||||||
DEFAULT_IP_PREFIX,
|
DEFAULT_IP_PREFIX,
|
||||||
)
|
)
|
||||||
from .coordinator import ProxmoxResourcesCoordinator, ProxmoxNodesCoordinator
|
from .coordinator import ProxmoxResourcesCoordinator, ProxmoxNodesCoordinator
|
||||||
|
from .services import async_register_services, async_unregister_services
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,14 +34,12 @@ async def _apply_options_now(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|||||||
new_ip_mode = str(_opt(entry, CONF_IP_MODE, DEFAULT_IP_MODE))
|
new_ip_mode = str(_opt(entry, CONF_IP_MODE, DEFAULT_IP_MODE))
|
||||||
new_ip_prefix = str(_opt(entry, CONF_IP_PREFIX, DEFAULT_IP_PREFIX))
|
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["scan_interval"] = new_scan_interval
|
||||||
data["ip_mode"] = new_ip_mode
|
data["ip_mode"] = new_ip_mode
|
||||||
data["ip_prefix"] = new_ip_prefix
|
data["ip_prefix"] = new_ip_prefix
|
||||||
|
|
||||||
td = timedelta(seconds=new_scan_interval)
|
td = timedelta(seconds=new_scan_interval)
|
||||||
|
|
||||||
# cluster coordinators
|
|
||||||
resources = data.get("resources")
|
resources = data.get("resources")
|
||||||
if resources:
|
if resources:
|
||||||
resources.update_interval = td
|
resources.update_interval = td
|
||||||
@@ -49,11 +48,9 @@ async def _apply_options_now(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|||||||
if nodes:
|
if nodes:
|
||||||
nodes.update_interval = td
|
nodes.update_interval = td
|
||||||
|
|
||||||
# node coordinators
|
|
||||||
for node_coord in (data.get("node_coordinators") or {}).values():
|
for node_coord in (data.get("node_coordinators") or {}).values():
|
||||||
node_coord.update_interval = td
|
node_coord.update_interval = td
|
||||||
|
|
||||||
# guest coordinators (also update ip preference config)
|
|
||||||
for guest_coord in (data.get("guest_coordinators") or {}).values():
|
for guest_coord in (data.get("guest_coordinators") or {}).values():
|
||||||
guest_coord.update_interval = td
|
guest_coord.update_interval = td
|
||||||
guest_coord.ip_mode = new_ip_mode
|
guest_coord.ip_mode = new_ip_mode
|
||||||
@@ -72,10 +69,8 @@ async def _apply_options_now(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|||||||
for guest_coord in (data.get("guest_coordinators") or {}).values():
|
for guest_coord in (data.get("guest_coordinators") or {}).values():
|
||||||
tasks.append(guest_coord.async_request_refresh())
|
tasks.append(guest_coord.async_request_refresh())
|
||||||
|
|
||||||
if tasks:
|
for t in tasks:
|
||||||
# don't fail all if one refresh fails
|
hass.async_create_task(t)
|
||||||
for t in tasks:
|
|
||||||
hass.async_create_task(t)
|
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Applied options live for %s: scan_interval=%s ip_mode=%s ip_prefix=%s",
|
"Applied options live for %s: scan_interval=%s ip_mode=%s ip_prefix=%s",
|
||||||
@@ -87,7 +82,6 @@ async def _apply_options_now(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Called by HA when options are changed."""
|
|
||||||
await _apply_options_now(hass, entry)
|
await _apply_options_now(hass, entry)
|
||||||
|
|
||||||
|
|
||||||
@@ -122,12 +116,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"scan_interval": scan_interval,
|
"scan_interval": scan_interval,
|
||||||
"ip_mode": ip_mode,
|
"ip_mode": ip_mode,
|
||||||
"ip_prefix": ip_prefix,
|
"ip_prefix": ip_prefix,
|
||||||
"guest_coordinators": {}, # (node, vmtype, vmid) -> ProxmoxGuestCoordinator
|
"guest_coordinators": {},
|
||||||
"node_coordinators": {}, # node -> ProxmoxNodeCoordinator
|
"node_coordinators": {},
|
||||||
"platform_cache": {}, # per-platform caches + unsub handles
|
"platform_cache": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Apply option updates live (gear icon -> save)
|
# Register services once
|
||||||
|
await async_register_services(hass)
|
||||||
|
|
||||||
|
# Apply option updates live
|
||||||
entry.async_on_unload(entry.add_update_listener(_update_listener))
|
entry.async_on_unload(entry.add_update_listener(_update_listener))
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
@@ -152,4 +149,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
if data.get("session"):
|
if data.get("session"):
|
||||||
await data["session"].close()
|
await data["session"].close()
|
||||||
|
|
||||||
|
# If no entries remain, unregister services
|
||||||
|
if not hass.data.get(DOMAIN):
|
||||||
|
await async_unregister_services(hass)
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "proxmox_pve",
|
"domain": "proxmox_pve",
|
||||||
"name": "Easy Proxmox (by René Bachmann)",
|
"name": "Easy Proxmox (by René Bachmann)",
|
||||||
"version": "0.6.0-alpha",
|
"version": "0.7.1",
|
||||||
"documentation": "https://example.invalid",
|
"documentation": "https://example.invalid",
|
||||||
"requirements": ["aiohttp>=3.9.0"],
|
"requirements": ["aiohttp>=3.9.0"],
|
||||||
"codeowners": ["@BAHMCLOUD"],
|
"codeowners": ["@BAHMCLOUD"],
|
||||||
@@ -9,4 +9,3 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"integration_type": "service"
|
"integration_type": "service"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
243
custom_components/proxmox_pve/services.py
Normal file
243
custom_components/proxmox_pve/services.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Tuple
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SERVICE_START = "start"
|
||||||
|
SERVICE_SHUTDOWN = "shutdown"
|
||||||
|
SERVICE_STOP_HARD = "stop_hard"
|
||||||
|
SERVICE_REBOOT = "reboot"
|
||||||
|
|
||||||
|
ATTR_DEVICE_ID = "device_id"
|
||||||
|
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
|
||||||
|
ATTR_HOST = "host"
|
||||||
|
ATTR_NODE = "node"
|
||||||
|
ATTR_VMID = "vmid"
|
||||||
|
ATTR_TYPE = "type"
|
||||||
|
|
||||||
|
VALID_TYPES = ("qemu", "lxc")
|
||||||
|
|
||||||
|
# IMPORTANT:
|
||||||
|
# HA may pass device_id via target (list) OR data (list) depending on UI/script wrapper.
|
||||||
|
SERVICE_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_DEVICE_ID): vol.Any(str, [str]),
|
||||||
|
vol.Optional(ATTR_CONFIG_ENTRY_ID): str,
|
||||||
|
vol.Optional(ATTR_HOST): str,
|
||||||
|
vol.Optional(ATTR_NODE): str,
|
||||||
|
vol.Optional(ATTR_VMID): vol.Coerce(int),
|
||||||
|
vol.Optional(ATTR_TYPE, default="qemu"): vol.In(VALID_TYPES),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_str(value: Any) -> str | None:
|
||||||
|
"""Return first string from str or list[str], else None."""
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
if isinstance(value, list) and value:
|
||||||
|
v0 = value[0]
|
||||||
|
if isinstance(v0, str) and v0.strip():
|
||||||
|
return v0.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_target_device_id(call: ServiceCall) -> str | None:
|
||||||
|
"""
|
||||||
|
Prefer call.target.device_id (UI target).
|
||||||
|
Fallback to call.data.device_id (some wrappers convert target to data).
|
||||||
|
"""
|
||||||
|
if call.target and isinstance(call.target, dict):
|
||||||
|
dev_id = _first_str(call.target.get("device_id"))
|
||||||
|
if dev_id:
|
||||||
|
return dev_id
|
||||||
|
|
||||||
|
# fallback: data.device_id can be str or list[str]
|
||||||
|
return _first_str(call.data.get(ATTR_DEVICE_ID))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_guest_identifier(identifier: str) -> Tuple[str, str, int]:
|
||||||
|
"""
|
||||||
|
Guest device identifier format: "node:type:vmid"
|
||||||
|
Example: "pve1:qemu:100"
|
||||||
|
"""
|
||||||
|
parts = identifier.split(":")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError(f"Invalid guest identifier: {identifier}")
|
||||||
|
node, vmtype, vmid_s = parts
|
||||||
|
vmid = int(vmid_s)
|
||||||
|
if vmtype not in VALID_TYPES:
|
||||||
|
raise ValueError(f"Invalid VM type: {vmtype}")
|
||||||
|
return node, vmtype, vmid
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target(hass: HomeAssistant, call: ServiceCall) -> Tuple[str, str, int]:
|
||||||
|
"""Resolve node/type/vmid from device target OR node+vmid (+ optional type)."""
|
||||||
|
device_id = _get_target_device_id(call)
|
||||||
|
node = call.data.get(ATTR_NODE)
|
||||||
|
vmid = call.data.get(ATTR_VMID)
|
||||||
|
vmtype = call.data.get(ATTR_TYPE, "qemu")
|
||||||
|
|
||||||
|
if device_id:
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
device = dev_reg.async_get(device_id)
|
||||||
|
if not device:
|
||||||
|
raise ValueError(f"Device not found: {device_id}")
|
||||||
|
|
||||||
|
for ident_domain, ident_value in device.identifiers:
|
||||||
|
if ident_domain != DOMAIN:
|
||||||
|
continue
|
||||||
|
if ident_value.startswith("node:"):
|
||||||
|
continue
|
||||||
|
return _parse_guest_identifier(ident_value)
|
||||||
|
|
||||||
|
raise ValueError(f"Selected device has no Easy Proxmox guest identifier: {device_id}")
|
||||||
|
|
||||||
|
if not node or vmid is None:
|
||||||
|
raise ValueError("Provide a Device target OR node + vmid (+ optional type/host/config_entry_id).")
|
||||||
|
|
||||||
|
if vmtype not in VALID_TYPES:
|
||||||
|
raise ValueError(f"Invalid type: {vmtype} (allowed: {VALID_TYPES})")
|
||||||
|
|
||||||
|
return str(node), str(vmtype), int(vmid)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_domain_entries(hass: HomeAssistant) -> dict[str, Any]:
|
||||||
|
domain_data: dict[str, Any] = hass.data.get(DOMAIN, {})
|
||||||
|
if not domain_data:
|
||||||
|
raise ValueError("Easy Proxmox is not set up.")
|
||||||
|
return domain_data
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_entry_id_for_device(hass: HomeAssistant, device_id: str) -> str:
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
device = dev_reg.async_get(device_id)
|
||||||
|
if not device:
|
||||||
|
raise ValueError(f"Device not found: {device_id}")
|
||||||
|
|
||||||
|
domain_entries = _get_domain_entries(hass)
|
||||||
|
candidates = [eid for eid in device.config_entries if eid in domain_entries]
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError("Device is not linked to any loaded Easy Proxmox config entry.")
|
||||||
|
if len(candidates) > 1:
|
||||||
|
_LOGGER.warning("Device %s belongs to multiple Easy Proxmox entries, using first.", device_id)
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_entry_id_by_host(hass: HomeAssistant, host: str) -> str:
|
||||||
|
domain_entries = _get_domain_entries(hass)
|
||||||
|
matches = []
|
||||||
|
for entry_id, data in domain_entries.items():
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
client = data.get("client")
|
||||||
|
if client and getattr(client, "host", None) == host:
|
||||||
|
matches.append(entry_id)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
raise ValueError(f"No Easy Proxmox entry found for host '{host}'.")
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise ValueError(f"Multiple Easy Proxmox entries found for host '{host}'. Please use config_entry_id.")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_entry_id_by_guest_lookup(hass: HomeAssistant, node: str, vmtype: str, vmid: int) -> str:
|
||||||
|
domain_entries = _get_domain_entries(hass)
|
||||||
|
matches = []
|
||||||
|
|
||||||
|
for entry_id, data in domain_entries.items():
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
resources = data.get("resources")
|
||||||
|
res_list = getattr(resources, "data", None)
|
||||||
|
if not res_list:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for r in res_list:
|
||||||
|
try:
|
||||||
|
if r.get("type") == vmtype and str(r.get("node")) == node and int(r.get("vmid")) == vmid:
|
||||||
|
matches.append(entry_id)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not find guest {node}/{vmtype}/{vmid} in any configured Proxmox host. "
|
||||||
|
"Provide host or config_entry_id, or use device target."
|
||||||
|
)
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Guest {node}/{vmtype}/{vmid} exists on multiple configured hosts (ambiguous). "
|
||||||
|
"Please provide host or config_entry_id, or use device target."
|
||||||
|
)
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_entry_id(hass: HomeAssistant, call: ServiceCall, target: Tuple[str, str, int]) -> str:
|
||||||
|
domain_entries = _get_domain_entries(hass)
|
||||||
|
|
||||||
|
config_entry_id = call.data.get(ATTR_CONFIG_ENTRY_ID)
|
||||||
|
if config_entry_id:
|
||||||
|
if config_entry_id not in domain_entries:
|
||||||
|
raise ValueError(f"config_entry_id '{config_entry_id}' not found or not loaded.")
|
||||||
|
return config_entry_id
|
||||||
|
|
||||||
|
device_id = _get_target_device_id(call)
|
||||||
|
if device_id:
|
||||||
|
return _pick_entry_id_for_device(hass, device_id)
|
||||||
|
|
||||||
|
host = call.data.get(ATTR_HOST)
|
||||||
|
if host:
|
||||||
|
return _pick_entry_id_by_host(hass, host)
|
||||||
|
|
||||||
|
node, vmtype, vmid = target
|
||||||
|
return _pick_entry_id_by_guest_lookup(hass, node, vmtype, vmid)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_register_services(hass: HomeAssistant) -> None:
|
||||||
|
if hass.services.has_service(DOMAIN, SERVICE_START):
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _call_action(call: ServiceCall, action: str) -> None:
|
||||||
|
node, vmtype, vmid = _resolve_target(hass, call)
|
||||||
|
entry_id = _resolve_entry_id(hass, call, (node, vmtype, vmid))
|
||||||
|
|
||||||
|
domain_entries = _get_domain_entries(hass)
|
||||||
|
entry_data = domain_entries.get(entry_id)
|
||||||
|
if not isinstance(entry_data, dict) or not entry_data.get("client"):
|
||||||
|
raise ValueError(f"Selected config entry '{entry_id}' has no client (not loaded).")
|
||||||
|
|
||||||
|
client = entry_data["client"]
|
||||||
|
_LOGGER.debug("Service action=%s entry=%s target=%s/%s/%s", action, entry_id, node, vmtype, vmid)
|
||||||
|
await client.guest_action(node=node, vmid=vmid, vmtype=vmtype, action=action)
|
||||||
|
|
||||||
|
async def handle_start(call: ServiceCall) -> None:
|
||||||
|
await _call_action(call, "start")
|
||||||
|
|
||||||
|
async def handle_shutdown(call: ServiceCall) -> None:
|
||||||
|
await _call_action(call, "shutdown")
|
||||||
|
|
||||||
|
async def handle_stop_hard(call: ServiceCall) -> None:
|
||||||
|
await _call_action(call, "stop")
|
||||||
|
|
||||||
|
async def handle_reboot(call: ServiceCall) -> None:
|
||||||
|
await _call_action(call, "reboot")
|
||||||
|
|
||||||
|
hass.services.async_register(DOMAIN, SERVICE_START, handle_start, schema=SERVICE_SCHEMA)
|
||||||
|
hass.services.async_register(DOMAIN, SERVICE_SHUTDOWN, handle_shutdown, schema=SERVICE_SCHEMA)
|
||||||
|
hass.services.async_register(DOMAIN, SERVICE_STOP_HARD, handle_stop_hard, schema=SERVICE_SCHEMA)
|
||||||
|
hass.services.async_register(DOMAIN, SERVICE_REBOOT, handle_reboot, schema=SERVICE_SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unregister_services(hass: HomeAssistant) -> None:
|
||||||
|
for svc in (SERVICE_START, SERVICE_SHUTDOWN, SERVICE_STOP_HARD, SERVICE_REBOOT):
|
||||||
|
if hass.services.has_service(DOMAIN, svc):
|
||||||
|
hass.services.async_remove(DOMAIN, svc)
|
||||||
111
custom_components/proxmox_pve/services.yaml
Normal file
111
custom_components/proxmox_pve/services.yaml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
start:
|
||||||
|
name: Start guest
|
||||||
|
description: Start a VM or container.
|
||||||
|
target:
|
||||||
|
device:
|
||||||
|
integration: proxmox_pve
|
||||||
|
fields:
|
||||||
|
config_entry_id:
|
||||||
|
name: Config entry id
|
||||||
|
description: Optional. Force a specific Proxmox host configuration entry.
|
||||||
|
example: "8d9f2e7b1c3d4a5f..."
|
||||||
|
host:
|
||||||
|
name: Host
|
||||||
|
description: Optional. Proxmox host/IP of the configured entry (only used when device is not selected).
|
||||||
|
example: "192.168.178.101"
|
||||||
|
node:
|
||||||
|
name: Node
|
||||||
|
description: Proxmox node name (only used when device is not selected).
|
||||||
|
example: "pve1"
|
||||||
|
vmid:
|
||||||
|
name: VMID
|
||||||
|
description: Guest VMID (only used when device is not selected).
|
||||||
|
example: 100
|
||||||
|
type:
|
||||||
|
name: Type
|
||||||
|
description: Guest type (only used when device is not selected).
|
||||||
|
example: "qemu"
|
||||||
|
|
||||||
|
shutdown:
|
||||||
|
name: Shutdown guest
|
||||||
|
description: Gracefully shutdown a VM or container.
|
||||||
|
target:
|
||||||
|
device:
|
||||||
|
integration: proxmox_pve
|
||||||
|
fields:
|
||||||
|
config_entry_id:
|
||||||
|
name: Config entry id
|
||||||
|
description: Optional. Force a specific Proxmox host configuration entry.
|
||||||
|
example: "8d9f2e7b1c3d4a5f..."
|
||||||
|
host:
|
||||||
|
name: Host
|
||||||
|
description: Optional. Proxmox host/IP of the configured entry (only used when device is not selected).
|
||||||
|
example: "192.168.178.101"
|
||||||
|
node:
|
||||||
|
name: Node
|
||||||
|
description: Proxmox node name (only used when device is not selected).
|
||||||
|
example: "pve1"
|
||||||
|
vmid:
|
||||||
|
name: VMID
|
||||||
|
description: Guest VMID (only used when device is not selected).
|
||||||
|
example: 100
|
||||||
|
type:
|
||||||
|
name: Type
|
||||||
|
description: Guest type (only used when device is not selected).
|
||||||
|
example: "qemu"
|
||||||
|
|
||||||
|
stop_hard:
|
||||||
|
name: Stop guest (hard)
|
||||||
|
description: Hard stop a VM or container (equivalent to Stop).
|
||||||
|
target:
|
||||||
|
device:
|
||||||
|
integration: proxmox_pve
|
||||||
|
fields:
|
||||||
|
config_entry_id:
|
||||||
|
name: Config entry id
|
||||||
|
description: Optional. Force a specific Proxmox host configuration entry.
|
||||||
|
example: "8d9f2e7b1c3d4a5f..."
|
||||||
|
host:
|
||||||
|
name: Host
|
||||||
|
description: Optional. Proxmox host/IP of the configured entry (only used when device is not selected).
|
||||||
|
example: "192.168.178.101"
|
||||||
|
node:
|
||||||
|
name: Node
|
||||||
|
description: Proxmox node name (only used when device is not selected).
|
||||||
|
example: "pve1"
|
||||||
|
vmid:
|
||||||
|
name: VMID
|
||||||
|
description: Guest VMID (only used when device is not selected).
|
||||||
|
example: 100
|
||||||
|
type:
|
||||||
|
name: Type
|
||||||
|
description: Guest type (only used when device is not selected).
|
||||||
|
example: "qemu"
|
||||||
|
|
||||||
|
reboot:
|
||||||
|
name: Reboot guest
|
||||||
|
description: Reboot a VM or container.
|
||||||
|
target:
|
||||||
|
device:
|
||||||
|
integration: proxmox_pve
|
||||||
|
fields:
|
||||||
|
config_entry_id:
|
||||||
|
name: Config entry id
|
||||||
|
description: Optional. Force a specific Proxmox host configuration entry.
|
||||||
|
example: "8d9f2e7b1c3d4a5f..."
|
||||||
|
host:
|
||||||
|
name: Host
|
||||||
|
description: Optional. Proxmox host/IP of the configured entry (only used when device is not selected).
|
||||||
|
example: "192.168.178.101"
|
||||||
|
node:
|
||||||
|
name: Node
|
||||||
|
description: Proxmox node name (only used when device is not selected).
|
||||||
|
example: "pve1"
|
||||||
|
vmid:
|
||||||
|
name: VMID
|
||||||
|
description: Guest VMID (only used when device is not selected).
|
||||||
|
example: 100
|
||||||
|
type:
|
||||||
|
name: Type
|
||||||
|
description: Guest type (only used when device is not selected).
|
||||||
|
example: "qemu"
|
||||||
Reference in New Issue
Block a user