Skip to content

Commit c02676b

Browse files
author
MrD3y5eL
committed
Improved VM Status reporting back to HA. Added extra attributes for VMs and Containers.
1 parent a18eac7 commit c02676b

File tree

5 files changed

+211
-36
lines changed

5 files changed

+211
-36
lines changed

custom_components/unraid/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Constants for the Unraid integration."""
22
from homeassistant.const import Platform
33

4+
45
DOMAIN = "unraid"
56
DEFAULT_PORT = 22
67
DEFAULT_CHECK_INTERVAL = 300 # seconds

custom_components/unraid/coordinator.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,22 @@ def __init__(self, hass: HomeAssistant, api: UnraidAPI, entry: ConfigEntry) -> N
3232
async def _async_update_data(self) -> Dict[str, Any]:
3333
"""Fetch data from Unraid."""
3434
try:
35+
# Fetch VM data first for faster switch response
36+
vms = await self.api.get_vms()
37+
38+
# Then fetch the rest of the data
3539
data = {
40+
"vms": vms,
3641
"system_stats": await self.api.get_system_stats(),
3742
"docker_containers": await self.api.get_docker_containers(),
38-
"vms": await self.api.get_vms(),
3943
"user_scripts": await self.api.get_user_scripts(),
4044
}
45+
4146
if self.has_ups:
4247
ups_info = await self.api.get_ups_info()
43-
if ups_info: # Only add UPS info if it's not empty
48+
if ups_info:
4449
data["ups_info"] = ups_info
50+
4551
return data
4652
except Exception as err:
4753
_LOGGER.error("Error communicating with Unraid: %s", err)

custom_components/unraid/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
"issue_tracker": "https://github.com/domalab/ha-unraid/issues",
1111
"requirements": [],
1212
"ssdp": [],
13-
"version": "0.1.3",
13+
"version": "0.1.4",
1414
"zeroconf": []
1515
}

custom_components/unraid/switch.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Switch platform for Unraid."""
22
from __future__ import annotations
33

4+
from typing import Any, Dict
5+
46
from homeassistant.components.switch import SwitchEntity
57
from homeassistant.config_entries import ConfigEntry
68
from homeassistant.core import HomeAssistant, callback
79
from homeassistant.helpers.entity_platform import AddEntitiesCallback
810
from homeassistant.helpers.update_coordinator import CoordinatorEntity
11+
from homeassistant.exceptions import HomeAssistantError
912

1013
from .const import DOMAIN
1114
from .coordinator import UnraidDataUpdateCoordinator
@@ -79,9 +82,21 @@ def is_on(self) -> bool:
7982
"""Return true if the container is running."""
8083
for container in self.coordinator.data["docker_containers"]:
8184
if container["name"] == self._container_name:
82-
return container["status"].lower() == "running"
85+
return container["state"] == "running"
8386
return False
8487

88+
@property
89+
def extra_state_attributes(self) -> Dict[str, Any]:
90+
"""Return the state attributes."""
91+
for container in self.coordinator.data["docker_containers"]:
92+
if container["name"] == self._container_name:
93+
return {
94+
"container_id": container["id"],
95+
"status": container["status"],
96+
"image": container["image"]
97+
}
98+
return {}
99+
85100
async def async_turn_on(self, **kwargs) -> None:
86101
"""Turn the container on."""
87102
await self.coordinator.api.start_container(self._container_name)
@@ -100,7 +115,27 @@ def __init__(self, coordinator: UnraidDataUpdateCoordinator, vm_name: str) -> No
100115
super().__init__(coordinator, f"vm_{vm_name}")
101116
self._vm_name = vm_name
102117
self._attr_name = f"Unraid VM {vm_name}"
103-
self._attr_icon = "mdi:desktop-classic"
118+
self._attr_entity_registry_enabled_default = True
119+
self._attr_assumed_state = False
120+
121+
@property
122+
def icon(self) -> str:
123+
"""Return the icon to use for the VM."""
124+
for vm in self.coordinator.data["vms"]:
125+
if vm["name"] == self._vm_name:
126+
if vm.get("os_type") == "windows":
127+
return "mdi:microsoft-windows"
128+
elif vm.get("os_type") == "linux":
129+
return "mdi:linux"
130+
return "mdi:desktop-tower"
131+
return "mdi:desktop-tower"
132+
133+
@property
134+
def available(self) -> bool:
135+
"""Return if the switch is available."""
136+
if not self.coordinator.last_update_success:
137+
return False
138+
return any(vm["name"] == self._vm_name for vm in self.coordinator.data["vms"])
104139

105140
@property
106141
def is_on(self) -> bool:
@@ -109,13 +144,28 @@ def is_on(self) -> bool:
109144
if vm["name"] == self._vm_name:
110145
return vm["status"].lower() == "running"
111146
return False
147+
148+
@property
149+
def extra_state_attributes(self) -> Dict[str, Any]:
150+
"""Return the state attributes."""
151+
for vm in self.coordinator.data["vms"]:
152+
if vm["name"] == self._vm_name:
153+
return {
154+
"os_type": vm.get("os_type", "unknown"),
155+
"status": vm.get("status", "unknown"),
156+
}
157+
return {}
112158

113159
async def async_turn_on(self, **kwargs) -> None:
114160
"""Turn the VM on."""
115-
await self.coordinator.api.start_vm(self._vm_name)
161+
success = await self.coordinator.api.start_vm(self._vm_name)
162+
if not success:
163+
raise HomeAssistantError(f"Failed to start VM {self._vm_name}")
116164
await self.coordinator.async_request_refresh()
117165

118166
async def async_turn_off(self, **kwargs) -> None:
119167
"""Turn the VM off."""
120-
await self.coordinator.api.stop_vm(self._vm_name)
168+
success = await self.coordinator.api.stop_vm(self._vm_name)
169+
if not success:
170+
raise HomeAssistantError(f"Failed to stop VM {self._vm_name}")
121171
await self.coordinator.async_request_refresh()

custom_components/unraid/unraid.py

Lines changed: 147 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,53 @@
55
from typing import Dict, List, Any, Optional
66
import re
77
from async_timeout import timeout
8+
from enum import Enum
9+
import json
810

911
_LOGGER = logging.getLogger(__name__)
1012

13+
class VMState(Enum):
14+
"""VM states matching Unraid/libvirt states."""
15+
RUNNING = 'running'
16+
STOPPED = 'shut off'
17+
PAUSED = 'paused'
18+
IDLE = 'idle'
19+
IN_SHUTDOWN = 'in shutdown'
20+
CRASHED = 'crashed'
21+
SUSPENDED = 'pmsuspended'
22+
23+
@classmethod
24+
def is_running(cls, state: str) -> bool:
25+
"""Check if the state represents a running VM."""
26+
return state.lower() == cls.RUNNING.value
27+
28+
@classmethod
29+
def parse(cls, state: str) -> str:
30+
"""Parse the VM state string."""
31+
state = state.lower().strip()
32+
try:
33+
return next(s.value for s in cls if s.value == state)
34+
except StopIteration:
35+
return state
36+
37+
class ContainerStates(Enum):
38+
"""Docker container states."""
39+
RUNNING = 'running'
40+
EXITED = 'exited'
41+
PAUSED = 'paused'
42+
RESTARTING = 'restarting'
43+
DEAD = 'dead'
44+
CREATED = 'created'
45+
46+
@classmethod
47+
def parse(cls, state: str) -> str:
48+
"""Parse the container state string."""
49+
state = state.lower().strip()
50+
try:
51+
return next(s.value for s in cls if s.value == state)
52+
except StopIteration:
53+
return state
54+
1155
class UnraidAPI:
1256
"""API client for interacting with Unraid servers."""
1357

@@ -397,18 +441,35 @@ async def get_docker_containers(self) -> List[Dict[str, Any]]:
397441
"""Fetch information about Docker containers."""
398442
try:
399443
_LOGGER.debug("Fetching Docker container information")
400-
result = await self.execute_command("docker ps -a --format '{{.Names}}|{{.State}}'")
444+
# Get basic container info with proven format
445+
result = await self.execute_command("docker ps -a --format '{{.Names}}|{{.State}}|{{.ID}}|{{.Image}}'")
401446
if result.exit_status != 0:
402447
_LOGGER.error("Docker container list command failed with exit status %d", result.exit_status)
403448
return []
404-
449+
405450
containers = []
406451
for line in result.stdout.splitlines():
407452
parts = line.split('|')
408-
if len(parts) == 2:
409-
containers.append({"name": parts[0], "status": parts[1]})
453+
if len(parts) == 4: # Now expecting 4 parts
454+
container_name = parts[0].strip()
455+
# Get container icon if available
456+
icon_path = f"/var/lib/docker/unraid/images/{container_name}-icon.png"
457+
icon_result = await self.execute_command(
458+
f"[ -f {icon_path} ] && (base64 {icon_path}) || echo ''"
459+
)
460+
icon_data = icon_result.stdout[0] if icon_result.exit_status == 0 else ""
461+
462+
containers.append({
463+
"name": container_name,
464+
"state": ContainerStates.parse(parts[1].strip()),
465+
"status": parts[1].strip(),
466+
"id": parts[2].strip(),
467+
"image": parts[3].strip(),
468+
"icon": icon_data
469+
})
410470
else:
411471
_LOGGER.warning("Unexpected format in docker container output: %s", line)
472+
412473
return containers
413474
except Exception as e:
414475
_LOGGER.error("Error getting docker containers: %s", str(e))
@@ -417,8 +478,8 @@ async def get_docker_containers(self) -> List[Dict[str, Any]]:
417478
async def start_container(self, container_name: str) -> bool:
418479
"""Start a Docker container."""
419480
try:
420-
_LOGGER.debug("Starting Docker container: %s", container_name)
421-
result = await self.execute_command(f"docker start {container_name}")
481+
_LOGGER.debug("Starting container: %s", container_name)
482+
result = await self.execute_command(f'docker start "{container_name}"')
422483
if result.exit_status != 0:
423484
_LOGGER.error("Failed to start container %s: %s", container_name, result.stderr)
424485
return False
@@ -427,12 +488,12 @@ async def start_container(self, container_name: str) -> bool:
427488
except Exception as e:
428489
_LOGGER.error("Error starting container %s: %s", container_name, str(e))
429490
return False
430-
491+
431492
async def stop_container(self, container_name: str) -> bool:
432493
"""Stop a Docker container."""
433494
try:
434-
_LOGGER.debug("Stopping Docker container: %s", container_name)
435-
result = await self.execute_command(f"docker stop {container_name}")
495+
_LOGGER.debug("Stopping container: %s", container_name)
496+
result = await self.execute_command(f'docker stop "{container_name}"')
436497
if result.exit_status != 0:
437498
_LOGGER.error("Failed to stop container %s: %s", container_name, result.stderr)
438499
return False
@@ -455,43 +516,100 @@ async def get_vms(self) -> List[Dict[str, Any]]:
455516
for line in result.stdout.splitlines():
456517
if line.strip():
457518
name = line.strip()
458-
status = await self._get_vm_status(name)
459-
vms.append({"name": name, "status": status})
519+
status = await self.get_vm_status(name)
520+
os_type = await self.get_vm_os_info(name)
521+
vms.append({
522+
"name": name,
523+
"status": status,
524+
"os_type": os_type
525+
})
460526
return vms
461527
except Exception as e:
462528
_LOGGER.error("Error getting VMs: %s", str(e))
463529
return []
530+
531+
async def get_vm_os_info(self, vm_name: str) -> str:
532+
"""Get the OS type of a VM."""
533+
try:
534+
# First try to get OS info from VM XML
535+
result = await self.execute_command(f'virsh dumpxml "{vm_name}" | grep "<os>"')
536+
xml_output = result.stdout
537+
538+
# Check for Windows-specific indicators
539+
if any(indicator in '\n'.join(xml_output).lower() for indicator in ['windows', 'win', 'microsoft']):
540+
return 'windows'
541+
542+
# Try to get detailed OS info if available
543+
result = await self.execute_command(f'virsh domosinfo "{vm_name}" 2>/dev/null')
544+
if result.exit_status == 0:
545+
os_info = '\n'.join(result.stdout).lower()
546+
if any(indicator in os_info for indicator in ['windows', 'win', 'microsoft']):
547+
return 'windows'
548+
elif any(indicator in os_info for indicator in ['linux', 'unix', 'ubuntu', 'debian', 'centos', 'fedora', 'rhel']):
549+
return 'linux'
550+
551+
# Default to checking common paths in VM name
552+
vm_name_lower = vm_name.lower()
553+
if any(win_term in vm_name_lower for win_term in ['windows', 'win']):
554+
return 'windows'
555+
elif any(linux_term in vm_name_lower for linux_term in ['linux', 'ubuntu', 'debian', 'centos', 'fedora', 'rhel']):
556+
return 'linux'
557+
558+
return 'unknown'
559+
except Exception as e:
560+
_LOGGER.debug("Error getting OS info for VM %s: %s", vm_name, str(e))
561+
return 'unknown'
464562

465-
async def _get_vm_status(self, vm_name: str) -> str:
466-
"""Get the status of a specific virtual machine."""
563+
async def get_vm_status(self, vm_name: str) -> str:
564+
"""Get detailed status of a specific virtual machine."""
467565
try:
468566
result = await self.execute_command(f"virsh domstate {vm_name}")
469567
if result.exit_status != 0:
470-
_LOGGER.error("VM status command for %s failed with exit status %d", vm_name, result.exit_status)
471-
return "unknown"
472-
return result.stdout.strip()
568+
_LOGGER.error("Failed to get VM status for %s: %s", vm_name, result.stderr)
569+
return VMState.CRASHED.value
570+
return VMState.parse(result.stdout.strip())
473571
except Exception as e:
474572
_LOGGER.error("Error getting VM status for %s: %s", vm_name, str(e))
475-
return "unknown"
573+
return VMState.CRASHED.value
476574

477-
async def start_vm(self, vm_name: str) -> bool:
478-
"""Start a virtual machine."""
575+
async def stop_vm(self, vm_name: str) -> bool:
576+
"""Stop a virtual machine using ACPI shutdown."""
479577
try:
480-
_LOGGER.debug("Starting VM: %s", vm_name)
481-
result = await self.execute_command(f"virsh start {vm_name}")
482-
return result.exit_status == 0 and "started" in result.stdout.lower()
578+
_LOGGER.debug("Stopping VM: %s", vm_name)
579+
result = await self.execute_command(f'virsh shutdown "{vm_name}" --mode acpi')
580+
success = result.exit_status == 0
581+
582+
if success:
583+
# Wait for the VM to actually shut down
584+
for _ in range(30): # Wait up to 60 seconds
585+
await asyncio.sleep(2)
586+
status = await self.get_vm_status(vm_name)
587+
if status == VMState.STOPPED.value:
588+
return True
589+
return False
590+
return False
483591
except Exception as e:
484-
_LOGGER.error("Error starting VM %s: %s", vm_name, str(e))
592+
_LOGGER.error("Error stopping VM %s: %s", vm_name, str(e))
485593
return False
486594

487-
async def stop_vm(self, vm_name: str) -> bool:
488-
"""Stop a virtual machine."""
595+
async def start_vm(self, vm_name: str) -> bool:
596+
"""Start a virtual machine and wait for it to be running."""
489597
try:
490-
_LOGGER.debug("Stopping VM: %s", vm_name)
491-
result = await self.execute_command(f"virsh shutdown {vm_name}")
492-
return result.exit_status == 0 and "shutting down" in result.stdout.lower()
598+
_LOGGER.debug("Starting VM: %s", vm_name)
599+
result = await self.execute_command(f'virsh start "{vm_name}"')
600+
success = result.exit_status == 0
601+
602+
if success:
603+
# Wait for the VM to actually start
604+
for _ in range(15): # Wait up to 30 seconds
605+
await asyncio.sleep(2)
606+
status = await self.get_vm_status(vm_name)
607+
if status == VMState.RUNNING.value:
608+
return True
609+
return False
610+
return False
493611
except Exception as e:
494-
_LOGGER.error("Error stopping VM %s: %s", vm_name, str(e))
612+
_LOGGER.error("Error starting VM %s: %s", vm_name, str(e))
495613
return False
496614

497615
async def get_user_scripts(self) -> List[Dict[str, Any]]:

0 commit comments

Comments
 (0)