Compare commits

...

3 Commits

5 changed files with 134 additions and 125 deletions

View File

@ -1,15 +1,16 @@
import subprocess as sp import asyncio
import paramiko import asyncssh
import wakeonlan from asyncssh import SSHClientConnection
from wakeonlan import send_magic_packet
from custom_components.easy_computer_manager import const, _LOGGER from custom_components.easy_computer_manager import const, _LOGGER
class OSType: class OSType:
WINDOWS = "Windows" WINDOWS = "windows"
LINUX = "Linux" LINUX = "linux"
MACOS = "MacOS" MACOS = "macos"
class Computer: class Computer:
@ -30,41 +31,55 @@ class Computer:
self._audio_config = None self._audio_config = None
self._bluetooth_devices = None self._bluetooth_devices = None
self.setup() self._connection: SSHClientConnection | None = None
async def _open_ssh_connection(self): asyncio.create_task(self.update())
"""Open an SSH connection."""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(self.host, port=self._port, username=self._username, password=self._password)
return client
def _close_ssh_connection(self, client): async def _open_ssh_connection(self) -> asyncssh.SSHClientConnection:
"""Open an asynchronous SSH connection."""
try:
client = await asyncssh.connect(
self.host,
username=self._username,
password=self._password,
port=self._port,
known_hosts=None
)
return client
except (OSError, asyncssh.Error) as exc:
_LOGGER.error(f"SSH connection failed: {exc}")
return None
async def _close_ssh_connection(self, client: asyncssh.SSHClientConnection) -> None:
"""Close the SSH connection.""" """Close the SSH connection."""
if client: client.close()
client.close()
def setup(self): async def update(self) -> None:
"""Setup method that opens an SSH connection and keeps it open for subsequent setup commands.""" """Setup method that opens an SSH connection and runs setup commands asynchronously."""
client = self._open_ssh_connection()
# TODO: run commands here # Open SSH connection or renew it if it is closed
self._operating_system = OSType.LINUX if self.run_manually( if self._connection is None or self._connection.is_closed:
"uname").return_code == 0 else OSType.WINDOWS # TODO: improve this self._connection = await self._open_ssh_connection()
self._operating_system_version = self.run_action("operating_system_version").output
self._windows_entry_grub = self.run_action("get_windows_entry_grub").output self._operating_system = OSType.LINUX if (await self.run_manually("uname")).get(
"return_code") == 0 else OSType.WINDOWS # TODO: improve this
self._operating_system_version = (await self.run_action("operating_system_version")).get("output")
self._windows_entry_grub = (await self.run_action("get_windows_entry_grub")).get("output")
self._monitors_config = {} self._monitors_config = {}
self._audio_config = {'speakers': None, 'microphones': None} self._audio_config = {'speakers': None, 'microphones': None}
self._bluetooth_devices = {} self._bluetooth_devices = {}
self._close_ssh_connection(client)
# Getters # Getters
def is_on(self, timeout: int = 1) -> bool: async def is_on(self, timeout: int = 1) -> bool:
"""Check if the computer is on (ping).""" """Check if the computer is on (ping)."""
ping_cmd = ["ping", "-c", "1", "-W", str(timeout), str(self.host)] ping_cmd = ["ping", "-c", "1", "-W", str(timeout), str(self.host)]
status = sp.call(ping_cmd, stdout=sp.DEVNULL, stderr=sp.DEVNULL) proc = await asyncio.create_subprocess_exec(
return status == 0 *ping_cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.communicate()
return proc.returncode == 0
def get_operating_system(self) -> str: def get_operating_system(self) -> str:
"""Get the operating system of the computer.""" """Get the operating system of the computer."""
@ -84,11 +99,11 @@ class Computer:
def get_speakers(self) -> str: def get_speakers(self) -> str:
"""Get the audio configuration of the computer.""" """Get the audio configuration of the computer."""
return self._audio_config.speakers return self._audio_config.get("speakers")
def get_microphones(self) -> str: def get_microphones(self) -> str:
"""Get the audio configuration of the computer.""" """Get the audio configuration of the computer."""
return self._audio_config.microphones return self._audio_config.get("microphones")
def get_bluetooth_devices(self, as_str: bool = False) -> str: def get_bluetooth_devices(self, as_str: bool = False) -> str:
"""Get the Bluetooth devices of the computer.""" """Get the Bluetooth devices of the computer."""
@ -99,84 +114,84 @@ class Computer:
return self._dualboot return self._dualboot
# Actions # Actions
def start(self) -> None: async def start(self) -> None:
"""Start the computer.""" """Start the computer."""
wakeonlan.send_magic_packet(self.mac) send_magic_packet(self.mac)
def shutdown(self) -> None: async def shutdown(self) -> None:
"""Shutdown the computer.""" """Shutdown the computer."""
self.run_action("shutdown") await self.run_action("shutdown")
def restart(self, from_os: OSType = None, to_os: OSType = None) -> None: async def restart(self, from_os: OSType = None, to_os: OSType = None) -> None:
"""Restart the computer.""" """Restart the computer."""
self.run_action("restart") await self.run_action("restart")
def put_to_sleep(self) -> None: async def put_to_sleep(self) -> None:
"""Put the computer to sleep.""" """Put the computer to sleep."""
self.run_action("sleep") await self.run_action("sleep")
def change_monitors_config(self, monitors_config: dict) -> None: async def change_monitors_config(self, monitors_config: dict) -> None:
pass pass
def change_audio_config(self, volume: int | None = None, mute: bool | None = None, input_device: str | None = None, async def change_audio_config(self, volume: int | None = None, mute: bool | None = None,
output_device: str | None = None) -> None: input_device: str | None = None,
output_device: str | None = None) -> None:
pass pass
def install_nircmd(self) -> None: async def install_nircmd(self) -> None:
pass pass
def start_steam_big_picture(self) -> None: async def start_steam_big_picture(self) -> None:
pass pass
def stop_steam_big_picture(self) -> None: async def stop_steam_big_picture(self) -> None:
pass pass
def exit_steam_big_picture(self) -> None: async def exit_steam_big_picture(self) -> None:
pass pass
def run_action(self, command: str, params=None) -> {}: async def run_action(self, action: str, params=None) -> dict:
"""Run a command via SSH. Opens a new connection for each command.""" """Run a command via SSH. Opens a new connection for each command."""
if params is None: if params is None:
params = {} params = {}
if command not in const.COMMANDS:
_LOGGER.error(f"Invalid command: {command}")
return
command_template = const.COMMANDS[command] if action in const.COMMANDS:
command_template = const.COMMANDS[action]
# Check if the command has the required parameters # Check if the command has the required parameters
if "params" in command_template: if "params" in command_template:
if sorted(command_template.params) != sorted(params.keys()): if sorted(command_template["params"]) != sorted(params.keys()):
raise ValueError("Invalid parameters") raise ValueError("Invalid parameters")
# Check if the command is available for the operating system # Check if the command is available for the operating system
match self._operating_system: if self._operating_system in command_template:
case OSType.WINDOWS: match self._operating_system:
command = command_template[OSType.WINDOWS] case OSType.WINDOWS:
case OSType.LINUX: commands = command_template[OSType.WINDOWS]
command = command_template[OSType.LINUX] case OSType.LINUX:
case _: commands = command_template[OSType.LINUX]
raise ValueError("Invalid operating system") case _:
raise ValueError("Invalid operating system")
# Replace the parameters in the command for command in commands:
for param in params: # Replace the parameters in the command
command = command.replace(f"%{param}%", params[param]) for param, value in params.items():
command = command.replace(f"%{param}%", value)
# Open SSH connection, execute command, and close connection result = await self.run_manually(command)
client = self._open_ssh_connection()
stdin, stdout, stderr = client.exec_command(command)
print(stdout.read().decode()) # Print the command output for debugging
self._close_ssh_connection(client)
return {"output": stdout.read().decode(), "error": stderr.read().decode(), if result['return_code'] == 0:
"return_code": stdout.channel.recv_exit_status()} _LOGGER.debug(f"Command successful: {command}")
return result
else:
_LOGGER.debug(f"Command failed: {command}")
def run_manually(self, command: str) -> {}: return {"output": "", "error": "", "return_code": 1}
async def run_manually(self, command: str) -> dict:
"""Run a command manually (not from predefined commands).""" """Run a command manually (not from predefined commands)."""
client = self._open_ssh_connection()
stdin, stdout, stderr = client.exec_command(command)
print(stdout.read().decode()) # Print the command output for debugging
self._close_ssh_connection(client)
return {"output": stdout.read().decode(), "error": stderr.read().decode(), result = await self._connection.run(command)
"return_code": stdout.channel.recv_exit_status()}
return {"output": result.stdout, "error": result.stderr,
"return_code": result.exit_status}

View File

@ -1,7 +1,7 @@
from custom_components.easy_computer_manager.computer import Computer from custom_components.easy_computer_manager.computer import Computer
def get_debug_info(computer: Computer): async def get_debug_info(computer: Computer):
"""Return debug information about the host system.""" """Return debug information about the host system."""
data = { data = {
@ -15,7 +15,7 @@ def get_debug_info(computer: Computer):
'username': computer._username, 'username': computer._username,
'port': computer._port, 'port': computer._port,
'dualboot': computer._dualboot, 'dualboot': computer._dualboot,
'is_on': computer.is_on() 'is_on': await computer.is_on()
}, },
'grub':{ 'grub':{
'windows_entry': computer.get_windows_entry_grub() 'windows_entry': computer.get_windows_entry_grub()

View File

@ -2,8 +2,7 @@
"domain": "easy_computer_manager", "domain": "easy_computer_manager",
"name": "Easy Computer Manager", "name": "Easy Computer Manager",
"codeowners": [ "codeowners": [
"@M4TH1EU", "@M4TH1EU"
"@ntilley905"
], ],
"config_flow": true, "config_flow": true,
"dependencies": [], "dependencies": [],
@ -11,10 +10,8 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/M4TH1EU/HA-EasyComputerManager/issues", "issue_tracker": "https://github.com/M4TH1EU/HA-EasyComputerManager/issues",
"requirements": [ "requirements": [
"construct==2.10.70",
"wakeonlan==3.1.0", "wakeonlan==3.1.0",
"fabric2==3.2.2", "asyncssh==2.16.0"
"paramiko==3.4.0"
], ],
"version": "1.3.0" "version": "2.0.0"
} }

View File

@ -17,7 +17,6 @@ from homeassistant.const import (
CONF_PORT, CONF_PORT,
CONF_USERNAME, ) CONF_USERNAME, )
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import ( from homeassistant.helpers import (
device_registry as dr, device_registry as dr,
entity_platform, entity_platform,
@ -26,7 +25,7 @@ from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import utils, computer_utils from . import computer_utils
from .computer import Computer, OSType from .computer import Computer, OSType
from .const import SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, SERVICE_PUT_COMPUTER_TO_SLEEP, \ from .const import SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, SERVICE_PUT_COMPUTER_TO_SLEEP, \
SERVICE_START_COMPUTER_TO_WINDOWS, SERVICE_RESTART_COMPUTER, SERVICE_RESTART_TO_LINUX_FROM_WINDOWS, \ SERVICE_START_COMPUTER_TO_WINDOWS, SERVICE_RESTART_COMPUTER, SERVICE_RESTART_TO_LINUX_FROM_WINDOWS, \
@ -150,74 +149,74 @@ class ComputerSwitch(SwitchEntity):
def is_on(self) -> bool: def is_on(self) -> bool:
return self._state return self._state
def turn_on(self, **kwargs: Any) -> None: async def turn_on(self, **kwargs: Any) -> None:
self.computer.start() await self.computer.start()
if self._attr_assumed_state: if self._attr_assumed_state:
self._state = True self._state = True
self.async_write_ha_state() self.async_write_ha_state()
def turn_off(self, **kwargs: Any) -> None: async def turn_off(self, **kwargs: Any) -> None:
self.computer.shutdown() await self.computer.shutdown()
if self._attr_assumed_state: if self._attr_assumed_state:
self._state = False self._state = False
self.async_write_ha_state() self.async_write_ha_state()
# Services # Services
def restart_to_windows_from_linux(self) -> None: async def restart_to_windows_from_linux(self) -> None:
"""Restart the computer to Windows from a running Linux by setting grub-reboot and restarting.""" """Restart the computer to Windows from a running Linux by setting grub-reboot and restarting."""
self.computer.restart(OSType.LINUX, OSType.WINDOWS) await self.computer.restart(OSType.LINUX, OSType.WINDOWS)
def restart_to_linux_from_windows(self) -> None: async def restart_to_linux_from_windows(self) -> None:
"""Restart the computer to Linux from a running Windows by setting grub-reboot and restarting.""" """Restart the computer to Linux from a running Windows by setting grub-reboot and restarting."""
self.computer.restart() await self.computer.restart()
def put_computer_to_sleep(self) -> None: async def put_computer_to_sleep(self) -> None:
self.computer.setup() await self.computer.put_to_sleep()
def start_computer_to_windows(self) -> None: async def start_computer_to_windows(self) -> None:
async def wait_task(): async def wait_task():
while not self.is_on: while not self.is_on:
pass await asyncio.sleep(3)
# await asyncio.sleep(3)
self.computer.restart(OSType.LINUX, OSType.WINDOWS) await self.computer.restart(OSType.LINUX, OSType.WINDOWS)
"""Start the computer to Linux, wait for it to boot, and then set grub-reboot and restart.""" """Start the computer to Linux, wait for it to boot, and then set grub-reboot and restart."""
self.computer.start() await self.computer.start()
self._hass.loop.create_task(wait_task()) self._hass.loop.create_task(wait_task())
def restart_computer(self) -> None: async def restart_computer(self) -> None:
self.computer.restart() await self.computer.restart()
def change_monitors_config(self, monitors_config: dict | None = None) -> None: async def change_monitors_config(self, monitors_config: dict | None = None) -> None:
"""Change the monitors configuration using a YAML config file.""" """Change the monitors configuration using a YAML config file."""
self.computer.change_monitors_config(monitors_config) await self.computer.change_monitors_config(monitors_config)
def steam_big_picture(self, action: str) -> None: async def steam_big_picture(self, action: str) -> None:
match action: match action:
case "start": case "start":
self.computer.start_steam_big_picture() await self.computer.start_steam_big_picture()
case "stop": case "stop":
self.computer.stop_steam_big_picture() await self.computer.stop_steam_big_picture()
case "exit": case "exit":
self.computer.exit_steam_big_picture() await self.computer.exit_steam_big_picture()
async def change_audio_config(self, volume: int | None = None, mute: bool | None = None,
def change_audio_config(self, volume: int | None = None, mute: bool | None = None, input_device: str | None = None, input_device: str | None = None,
output_device: str | None = None) -> None: output_device: str | None = None) -> None:
"""Change the audio configuration using a YAML config file.""" """Change the audio configuration using a YAML config file."""
self.computer.change_audio_config(volume, mute, input_device, output_device) await self.computer.change_audio_config(volume, mute, input_device, output_device)
def debug_info(self) -> ServiceResponse: async def debug_info(self) -> ServiceResponse:
"""Prints debug info.""" """Prints debug info."""
return computer_utils.get_debug_info(self.computer) return await computer_utils.get_debug_info(self.computer)
async def async_update(self) -> None:
def update(self) -> None:
"""Ping the computer to see if it is online and update the state.""" """Ping the computer to see if it is online and update the state."""
self._state = self.computer.is_on() self._state = await self.computer.is_on()
await self.computer.update()
# Update the state attributes and the connection only if the computer is on # Update the state attributes and the connection only if the computer is on
if self._state: if self._state:

View File

@ -1,5 +1,3 @@
fabric2~=3.2.2
paramiko~=3.4.0
voluptuous~=0.14.2
wakeonlan~=3.1.0 wakeonlan~=3.1.0
homeassistant~=2024.3.1 homeassistant~=2024.3.1
asyncssh~=2.16.0