Skip to content

Commit

Permalink
Enhanced User Scripts Function. It now detects user scripts and creat…
Browse files Browse the repository at this point in the history
…es a button to allow user to run script in foreground or background. Disabled by default when integration loads
  • Loading branch information
MrD3y5eL committed Dec 12, 2024
1 parent 49349ca commit 28c6c7f
Show file tree
Hide file tree
Showing 2 changed files with 237 additions and 10 deletions.
65 changes: 59 additions & 6 deletions custom_components/unraid/api/userscript_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,64 @@ async def execute_user_script(self, script_name: str, background: bool = False)
"""Execute a user script."""
try:
_LOGGER.debug("Executing user script: %s", script_name)
command = f"/usr/local/emhttp/plugins/user.scripts/scripts/{script_name}"

# Build proper script paths
script_dir = f"/boot/config/plugins/user.scripts/scripts/{script_name}"
script_path = f"{script_dir}/script"

# Check if script exists first
check_result = await self.execute_command(f'test -f "{script_path}" && echo "exists"')
if check_result.exit_status != 0 or "exists" not in check_result.stdout:
_LOGGER.error("Script %s not found at %s", script_name, script_path)
return ""

# First try direct execution
try:
_LOGGER.debug("Attempting direct script execution: %s", script_path)
result = await self.execute_command(f'bash "{script_path}"')
if result.exit_status == 0:
return result.stdout
except Exception as err:
_LOGGER.debug("Direct execution failed, trying PHP conversion: %s", err)

# If direct execution fails, try PHP conversion
php_cmd = (
f'php -r \'$_POST["action"]="convertScript"; '
f'$_POST["path"]="{script_path}"; '
f'include("/usr/local/emhttp/plugins/user.scripts/exec.php");\''
)

_LOGGER.debug("Running PHP convert command: %s", php_cmd)
convert_result = await self.execute_command(php_cmd)

if convert_result.exit_status != 0:
_LOGGER.error(
"Failed to convert script %s: %s",
script_name,
convert_result.stderr or convert_result.stdout
)
return ""

_LOGGER.debug("Script conversion output: %s", convert_result.stdout)

# Execute the script
if background:
command += " & > /dev/null 2>&1"
result = await self.execute_command(command)
result = await self.execute_command(f'nohup "{script_path}" > /dev/null 2>&1 &')
else:
result = await self.execute_command(f'bash "{script_path}"')

if result.exit_status != 0:
_LOGGER.error("User script %s failed with exit status %d", script_name, result.exit_status)
_LOGGER.error(
"Script %s failed with exit status %d: %s",
script_name,
result.exit_status,
result.stderr or result.stdout
)
return ""

return result.stdout
except (asyncssh.Error, asyncio.TimeoutError, OSError, ValueError) as e:

except Exception as e:
_LOGGER.error("Error executing user script %s: %s", script_name, str(e))
return ""

Expand All @@ -55,7 +104,11 @@ async def stop_user_script(self, script_name: str) -> str:
_LOGGER.debug("Stopping user script: %s", script_name)
result = await self.execute_command(f"pkill -f '{script_name}'")
if result.exit_status != 0:
_LOGGER.error("Stopping user script %s failed with exit status %d", script_name, result.exit_status)
_LOGGER.error(
"Stopping user script %s failed with exit status %d",
script_name,
result.exit_status
)
return ""
return result.stdout
except (asyncssh.Error, asyncio.TimeoutError, OSError, ValueError) as e:
Expand Down
182 changes: 178 additions & 4 deletions custom_components/unraid/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.config_entries import ConfigEntry # type: ignore # type: ignore
from homeassistant.config_entries import ConfigEntry # type: ignore
from homeassistant.core import HomeAssistant # type: ignore
from homeassistant.helpers.entity import EntityCategory # type: ignore
from homeassistant.helpers.entity_platform import AddEntitiesCallback # type: ignore
from homeassistant.exceptions import HomeAssistantError # type: ignore
from homeassistant.util import dt as dt_util # type: ignore

from .const import DOMAIN
from .coordinator import UnraidDataUpdateCoordinator
Expand All @@ -26,23 +27,86 @@ class UnraidButtonDescription(ButtonEntityDescription):

press_fn: Callable[[Any], Any] | None = None
icon: str | None = None
entity_registry_enabled_default: bool = False

@dataclass
class UnraidScriptButtonDescription(ButtonEntityDescription):
"""Class describing Unraid script button entities."""
script_name: str = ""
background: bool = False

BUTTON_TYPES: tuple[UnraidButtonDescription, ...] = (
UnraidButtonDescription(
key="reboot",
name="Reboot",
icon="mdi:restart",
press_fn=lambda api: api.system_reboot(delay=0),
entity_registry_enabled_default=True,
),
UnraidButtonDescription(
key="shutdown",
name="Shutdown",
icon="mdi:power",
press_fn=lambda api: api.system_shutdown(delay=0),
entity_registry_enabled_default=True,
),
)

def get_script_buttons(coordinator: UnraidDataUpdateCoordinator) -> list[ButtonEntity]:
"""Get button entities for user scripts."""
buttons = []

if not coordinator.data:
_LOGGER.warning("No data available from coordinator")
return buttons

scripts = coordinator.data.get("user_scripts", [])
if not scripts:
_LOGGER.debug("No user scripts found")
return buttons

for script in scripts:
# Create foreground button if supported
if not script.get("background_only", False):
buttons.append(
UnraidScriptButton(
coordinator,
UnraidScriptButtonDescription(
key=f"{script['name']}_run",
name=f"Run {script['name']}",
script_name=script["name"],
background=False,
icon="mdi:script-text-play",
),
)
)

# Create background button if supported
if not script.get("foreground_only", False):
buttons.append(
UnraidScriptButton(
coordinator,
UnraidScriptButtonDescription(
key=f"{script['name']}_background",
name=f"Run {script['name']} (Background)",
script_name=script["name"],
background=True,
icon="mdi:script-text-play-outline",
),
)
)

return buttons

def truncate_output(output: str, max_length: int = 1000) -> str:
"""Truncate output to a reasonable size and add notice if truncated."""
if not output:
return ""
if len(output) > max_length:
truncated = output[:max_length]
return f"{truncated}... (Output truncated, full length: {len(output)} bytes)"
return output

async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
Expand All @@ -53,16 +117,27 @@ async def async_setup_entry(

_LOGGER.debug("Setting up Unraid buttons for %s", coordinator.hostname)

async_add_entities(
# Add system buttons (reboot/shutdown)
entities = [
UnraidButton(coordinator, description)
for description in BUTTON_TYPES
)
]

# Add script buttons
try:
script_buttons = get_script_buttons(coordinator)
entities.extend(script_buttons)
except Exception as err:
_LOGGER.error("Error setting up script buttons: %s", err)

async_add_entities(entities)

class UnraidButton(ButtonEntity):
"""Representation of an Unraid button."""

_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
_attr_entity_registry_enabled_default = False # Disabled by default

def __init__(
self,
Expand Down Expand Up @@ -109,4 +184,103 @@ async def async_press(self) -> None:
self.entity_description.key,
err
)
raise HomeAssistantError(f"Failed to execute {self.name} command: {err}") from err
raise HomeAssistantError(f"Failed to execute {self.name} command: {err}") from err

class UnraidScriptButton(ButtonEntity):
"""Representation of an Unraid script button."""

_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
_attr_entity_registry_enabled_default = False # Disabled by default

def __init__(
self,
coordinator: UnraidDataUpdateCoordinator,
description: UnraidScriptButtonDescription,
) -> None:
"""Initialize the button."""
super().__init__()
self._attr_extra_state_attributes = {} # Instance-level attribute
self.coordinator = coordinator
self.entity_description: UnraidScriptButtonDescription = description

# Set unique_id combining entry_id and button key (removed duplicate)
self._attr_unique_id = f"{coordinator.entry.entry_id}_{description.key}"

# Set name and icon
self._attr_name = description.name
if description.icon:
self._attr_icon = description.icon

# Set device info
self._attr_device_info = {
"identifiers": {(DOMAIN, coordinator.entry.entry_id)},
"name": f"Unraid Server ({coordinator.hostname})",
"manufacturer": "Lime Technology",
"model": "Unraid Server",
}

async def async_press(self) -> None:
"""Handle the button press."""
try:
script_name = self.entity_description.script_name
background = self.entity_description.background

_LOGGER.info(
"Executing script %s in %s mode on Unraid instance: %s",
script_name,
"background" if background else "foreground",
self.coordinator.hostname,
)

# Update running state and add execution info
self._attr_extra_state_attributes.update({
"running": True,
"last_executed_at": dt_util.now().isoformat(),
"execution_type": "background" if background else "foreground",
"status": "running"
})
self.async_write_ha_state()

# Execute script
result = await self.coordinator.api.execute_user_script(
script_name,
background=background
)

# For background scripts, keep running state true
is_running = background

# Update completion state with truncated output
self._attr_extra_state_attributes.update({
"running": is_running,
"status": "running" if is_running else "completed",
"last_result": truncate_output(result) if result else "No output",
"completed_at": dt_util.now().isoformat() if not is_running else None
})
self.async_write_ha_state()

# Request a coordinator update to refresh script states
await self.coordinator.async_request_refresh()

except Exception as err:
# Get the script name safely
try:
script_name = self.entity_description.script_name
except AttributeError:
script_name = "unknown"

# Create error message
error_msg = f"Failed to execute script {script_name}: {str(err)}"

# Update state attributes
self._attr_extra_state_attributes.update({
"running": False,
"status": "error",
"error": str(err),
"error_at": dt_util.now().isoformat()
})
self.async_write_ha_state()

_LOGGER.error(error_msg)
raise HomeAssistantError(error_msg) from err

0 comments on commit 28c6c7f

Please sign in to comment.