From 51f3af16d7daa47baf2f537c2e2c56858485d4f3 Mon Sep 17 00:00:00 2001 From: mathieu Date: Tue, 4 Apr 2023 13:44:07 +0200 Subject: [PATCH] move from homeassistant core to hacs addon --- README.md | 33 +++++- __init__.py | 66 +++++++++++ config_flow.py | 107 ++++++++++++++++++ const.py | 7 ++ manifest.json | 20 ++++ services.yaml | 43 ++++++++ strings.json | 25 +++++ switch.py | 254 +++++++++++++++++++++++++++++++++++++++++++ translations/en.json | 25 +++++ translations/fr.json | 25 +++++ utils.py | 193 ++++++++++++++++++++++++++++++++ 11 files changed, 797 insertions(+), 1 deletion(-) create mode 100644 __init__.py create mode 100644 config_flow.py create mode 100644 const.py create mode 100644 manifest.json create mode 100644 services.yaml create mode 100644 strings.json create mode 100644 switch.py create mode 100644 translations/en.json create mode 100644 translations/fr.json create mode 100644 utils.py diff --git a/README.md b/README.md index 0e9d966..4585707 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,33 @@ -# HA-EasyComputerManage +# Easy Computer Manage + +## Configure Linux-running computer to be managed by Home Assistant. +We need to allow your user to run specific sudo command without asking for password. To do this, we need to edit sudoers file. To do this, run the following command in terminal: ``visudo`` + +``` +# Allow your user user to execute shutdown, init, systemctl, pm-suspend, awk, grub-reboot, and grub2-reboot without a password +username ALL=(ALL) NOPASSWD: /sbin/shutdown +username ALL=(ALL) NOPASSWD: /sbin/init +username ALL=(ALL) NOPASSWD: /usr/bin/systemctl +username ALL=(ALL) NOPASSWD: /usr/sbin/pm-suspend +username ALL=(ALL) NOPASSWD: /usr/bin/awk +username ALL=(ALL) NOPASSWD: /usr/sbin/grub-reboot +username ALL=(ALL) NOPASSWD: /usr/sbin/grub2-reboot +``` +Be sure to replace username with your username. + +## Configure Windows-running computer to be managed by Home Assistant. +First go to "Optional Features" in Windows 10, look for "OpenSSH Server" and install it. +Then open "Services", find "OpenSSH Server", open "Properties" and set the service to start "Automatically", you can also manually start the service for the first time. + +*Note : It might be necessary to allow port 22 (ssh) in the Windows firewall.* + +## Configure dual-boot (Windows/Linux) computer to be managed by Home Assistant. +To configure dual-boot computer, you need to configure both Windows and Linux, for this look at the 2 sections above. +You will need to have the same username and password on both Windows and Linux. + +*Note : Be sure to enable the checkbox "Dual boot system" when adding your PC to home assistant.* + +## Why not use SSH keys? +Well, simply because it would require the user to do some extra steps. Using the password, it's almost plug and play. +But maybe in the future I will add the option to use SSH keys depending on the feedback. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a4f9a51 --- /dev/null +++ b/__init__.py @@ -0,0 +1,66 @@ +"""The Easy Dualboot Computer Manage integration.""" + +# Some code is from the official wake_on_lan integration + +from __future__ import annotations + +import logging +from functools import partial + +import voluptuous as vol +import wakeonlan + +import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC +from homeassistant.core import HomeAssistant, ServiceCall, callback +from .const import DOMAIN, SERVICE_SEND_MAGIC_PACKET + +_LOGGER = logging.getLogger(__name__) + +WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA = vol.Schema( + { + vol.Required(CONF_MAC): cv.string, + vol.Optional(CONF_BROADCAST_ADDRESS): cv.string, + vol.Optional(CONF_BROADCAST_PORT): cv.port, + } +) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the wake on LAN component.""" + + @callback + async def send_magic_packet(call: ServiceCall) -> None: + """Send magic packet to wake up a device.""" + mac_address = call.data.get(CONF_MAC) + broadcast_address = call.data.get(CONF_BROADCAST_ADDRESS) + broadcast_port = call.data.get(CONF_BROADCAST_PORT) + + service_kwargs = {} + if broadcast_address is not None: + service_kwargs["ip_address"] = broadcast_address + if broadcast_port is not None: + service_kwargs["port"] = broadcast_port + + _LOGGER.info( + "Send magic packet to mac %s (broadcast: %s, port: %s)", + mac_address, + broadcast_address, + broadcast_port, + ) + + await hass.async_add_executor_job( + partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SEND_MAGIC_PACKET, + send_magic_packet, + schema=WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA, + ) + + hass.config_entries.async_setup_platforms(entry, ["switch"]) + + return True diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..cfc231f --- /dev/null +++ b/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for Easy Computer Manage integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from paramiko.ssh_exception import AuthenticationException + +from homeassistant import config_entries, exceptions +from homeassistant.core import HomeAssistant +from . import utils +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Optional("name"): str, + vol.Required("host"): str, + vol.Required("mac"): str, + vol.Required("dualboot"): bool, + vol.Required("username"): str, + vol.Required("password"): str, + vol.Optional("port", default=22): int, + } +) + + +class Hub: + """Dummy for test connection""" + + def __init__(self, hass: HomeAssistant, host: str, username: str, password: str, port: int) -> None: + """Init dummy hub.""" + self._host = host + self._username = username + self._password = password + self._port = port + self._hass = hass + self._name = host + self._id = host.lower() + + @property + def hub_id(self) -> str: + """ID for dummy.""" + return self._id + + async def test_connection(self) -> bool: + """Test connectivity to the computer is OK.""" + try: + return utils.test_connection(utils.create_ssh_connection(self._host, self._username, self._password, self._port)) + except AuthenticationException: + return False + + +async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: + """Validate the user input allows us to connect. + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + if len(data["host"]) < 3: + raise InvalidHost + + hub = Hub(hass, data["host"], data["username"], data["password"], data["port"]) + + result = await hub.test_connection() + if not result: + raise CannotConnect + + return {"title": data["host"]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + + return self.async_create_entry(title=info["title"], data=user_input) + except AuthenticationException: + errors["host"] = "cannot_connect" + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidHost: + errors["host"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + # If there is no user input or there were errors, show the form again, including any errors that were found + # with the input. + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidHost(exceptions.HomeAssistantError): + """Error to indicate there is an invalid hostname.""" diff --git a/const.py b/const.py new file mode 100644 index 0000000..2f344bd --- /dev/null +++ b/const.py @@ -0,0 +1,7 @@ +"""Constants for the Easy Computer Manage integration.""" + +DOMAIN = "easy_computer_manage" +SERVICE_SEND_MAGIC_PACKET = "send_magic_packet" +SERVICE_RESTART_TO_WINDOWS_FROM_LINUX = "restart_to_windows_from_linux" +SERVICE_PUT_COMPUTER_TO_SLEEP = "put_computer_to_sleep" +SERVICE_START_COMPUTER_TO_WINDOWS = "start_computer_to_windows" diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..9d5e9ed --- /dev/null +++ b/manifest.json @@ -0,0 +1,20 @@ +{ + "domain": "easy_computer_manage", + "name": "Easy Dualboot Computer Manage", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/easy_computer_manage", + "requirements": [ + "wakeonlan==2.1.0", + "fabric2==2.7.1" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@M4TH1EU", + "@ntilley905" + ], + "iot_class": "local_polling", + "version": "0.0.1" +} diff --git a/services.yaml b/services.yaml new file mode 100644 index 0000000..7bfbf3b --- /dev/null +++ b/services.yaml @@ -0,0 +1,43 @@ +send_magic_packet: + name: Send magic packet + description: Send a 'magic packet' to wake up a device with 'Wake-On-LAN' capabilities. + fields: + mac: + name: MAC address + description: MAC address of the device to wake up. + required: true + example: "aa:bb:cc:dd:ee:ff" + selector: + text: + broadcast_address: + name: Broadcast address + description: Broadcast IP where to send the magic packet. + example: 192.168.255.255 + selector: + text: + broadcast_port: + name: Broadcast port + description: Port where to send the magic packet. + default: 9 + selector: + number: + min: 1 + max: 65535 +restart_to_windows_from_linux: + name: Reboot to Windows from Linux + description: Reboot the computer to Windows when running Linux using Grub. + target: + device: + integration: easy_computer_manage +start_computer_to_windows: + name: Start computer to Windows + description: Start the computer directly Windows (boots to Linux, set grub reboot, then boots to Windows). + target: + device: + integration: easy_computer_manage +put_computer_to_sleep: + name: Put computer to sleep + description: Put the computer to sleep. + target: + device: + integration: easy_computer_manage \ No newline at end of file diff --git a/strings.json b/strings.json new file mode 100644 index 0000000..dfcedd3 --- /dev/null +++ b/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "dualboot": "[%key:common::config_flow::data::dualboot%]", + "port": "[%key:common::config_flow::data::port%]", + "name": "[%key:common::config_flow::data::name%]", + "mac": "[%key:common::config_flow::data::name%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/switch.py b/switch.py new file mode 100644 index 0000000..55aa9cf --- /dev/null +++ b/switch.py @@ -0,0 +1,254 @@ +# Some code is from the official wake_on_lan integration + +from __future__ import annotations + +import asyncio +import logging +import subprocess as sp +import threading +import time +from typing import Any + +import voluptuous as vol +import wakeonlan +from paramiko.ssh_exception import NoValidConnectionsError, SSHException, AuthenticationException + +from homeassistant.components.switch import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + SwitchEntity, +) +from homeassistant.const import ( + CONF_BROADCAST_ADDRESS, + CONF_BROADCAST_PORT, + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import utils +from .const import SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, SERVICE_PUT_COMPUTER_TO_SLEEP, \ + SERVICE_START_COMPUTER_TO_WINDOWS +from homeassistant.config_entries import ConfigEntry + +_LOGGER = logging.getLogger(__name__) + +CONF_OFF_ACTION = "turn_off" + +DEFAULT_NAME = "Computer Management (WoL, SoL)" +DEFAULT_PING_TIMEOUT = 1 + +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MAC): cv.string, + vol.Optional(CONF_BROADCAST_ADDRESS): cv.string, + vol.Optional(CONF_BROADCAST_PORT): cv.port, + vol.Required(CONF_HOST): cv.string, + vol.Required("dualboot", default=False): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_USERNAME, default="root"): cv.string, + vol.Required(CONF_PASSWORD, default="root"): cv.string, + vol.Optional(CONF_PORT, default=22): cv.string, + } +) + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + mac_address: str = config.data.get(CONF_MAC) + broadcast_address: str | None = config.data.get(CONF_BROADCAST_ADDRESS) + broadcast_port: int | None = config.data.get(CONF_BROADCAST_PORT) + host: str = config.data.get(CONF_HOST) + name: str = config.data.get(CONF_NAME) + dualboot: bool = config.data.get("dualboot") + username: str = config.data.get(CONF_USERNAME) + password: str = config.data.get(CONF_PASSWORD) + port: int | None = config.data.get(CONF_PORT) + + async_add_entities( + [ + ComputerSwitch( + hass, + name, + host, + mac_address, + broadcast_address, + broadcast_port, + dualboot, + username, + password, + port, + ), + ], + host is not None, + ) + + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, + {}, + SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, + ) + platform.async_register_entity_service( + SERVICE_PUT_COMPUTER_TO_SLEEP, + {}, + SERVICE_PUT_COMPUTER_TO_SLEEP, + ) + platform.async_register_entity_service( + SERVICE_START_COMPUTER_TO_WINDOWS, + {}, + SERVICE_START_COMPUTER_TO_WINDOWS, + ) + + +class ComputerSwitch(SwitchEntity): + """Representation of a computer switch.""" + + def __init__( + self, + hass: HomeAssistant, + name: str, + host: str | None, + mac_address: str, + broadcast_address: str | None, + broadcast_port: int | None, + dualboot: bool | False, + username: str, + password: str, + port: int | None, + ) -> None: + """Initialize the WOL switch.""" + + self._hass = hass + self._attr_name = name + self._host = host + self._mac_address = mac_address + self._broadcast_address = broadcast_address + self._broadcast_port = broadcast_port + self._dualboot = dualboot + self._username = username + self._password = password + self._port = port + self._state = False + self._attr_assumed_state = host is None + self._attr_should_poll = bool(not self._attr_assumed_state) + self._attr_unique_id = dr.format_mac(mac_address) + self._attr_extra_state_attributes = {} + self._connection = utils.create_ssh_connection(self._host, self._username, self._password) + + @property + def is_on(self) -> bool: + """Return true if the computer switch is on.""" + return self._state + + def turn_on(self, **kwargs: Any) -> None: + """Turn the computer on using wake on lan.""" + service_kwargs: dict[str, Any] = {} + if self._broadcast_address is not None: + service_kwargs["ip_address"] = self._broadcast_address + if self._broadcast_port is not None: + service_kwargs["port"] = self._broadcast_port + + _LOGGER.info( + "Send magic packet to mac %s (broadcast: %s, port: %s)", + self._mac_address, + self._broadcast_address, + self._broadcast_port, + ) + + wakeonlan.send_magic_packet(self._mac_address, **service_kwargs) + + if self._attr_assumed_state: + self._state = True + self.async_write_ha_state() + + def turn_off(self, **kwargs: Any) -> None: + """Turn the computer off using appropriate shutdown command based on running OS and/or distro.""" + utils.shutdown_system(self._connection) + + if self._attr_assumed_state: + self._state = False + self.async_write_ha_state() + + def restart_to_windows_from_linux(self) -> None: + """Restart the computer to Windows from a running Linux by setting grub-reboot and restarting.""" + + if self._dualboot: + utils.restart_to_windows_from_linux(self._connection) + else: + _LOGGER.error("This computer is not running a dualboot system.") + + def put_computer_to_sleep(self) -> None: + """Put the computer to sleep using appropriate sleep command based on running OS and/or distro.""" + utils.sleep_system(self._connection) + + def start_computer_to_windows(self) -> None: + """Start the computer to Linux, wait for it to boot, and then set grub-reboot and restart.""" + self.turn_on() + + if self._dualboot: + # Wait for the computer to boot using a dedicated thread to avoid blocking the main thread + + self._hass.loop.create_task(self.reboot_computer_to_windows_when_on()) + + else: + _LOGGER.error("This computer is not running a dualboot system.") + + async def reboot_computer_to_windows_when_on(self) -> None: + """Method to be run in a separate thread to wait for the computer to boot and then reboot to Windows.""" + while not self.is_on: + await asyncio.sleep(3) + + await utils.restart_to_windows_from_linux(self._connection) + + def update(self) -> None: + """Ping the computer to see if it is online and update the state.""" + ping_cmd = [ + "ping", + "-c", + "1", + "-W", + str(DEFAULT_PING_TIMEOUT), + str(self._host), + ] + status = sp.call(ping_cmd, stdout=sp.DEVNULL, stderr=sp.DEVNULL) + self._state = not bool(status) + + # Update the state attributes and the connection only if the computer is on + if self._state: + if not utils.test_connection(self._connection): + _LOGGER.info("RENEWING SSH CONNECTION") + + if self._connection is not None: + self._connection.close() + + self._connection = utils.create_ssh_connection(self._host, self._username, self._password) + + try: + self._connection.open() + except NoValidConnectionsError and SSHException as error: + _LOGGER.error("Could not connect to %s: %s", self._host, error) + self._state = False + return + except AuthenticationException as error: + _LOGGER.error("Could not authenticate to %s: %s", self._host, error) + self._state = False + return + + self._attr_extra_state_attributes = { + "operating_system": utils.get_operating_system(self._connection), + "operating_system_version": utils.get_operating_system_version( + self._connection + ), + } diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..7bd761f --- /dev/null +++ b/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "username": "Username", + "password": "Password", + "dualboot": "Is this a Linux/Windows dualboot computer?", + "port": "Port", + "name": "Name", + "mac": "MAC Address" + } + } + } + } +} \ No newline at end of file diff --git a/translations/fr.json b/translations/fr.json new file mode 100644 index 0000000..0969cfb --- /dev/null +++ b/translations/fr.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est déjà configuré." + }, + "error": { + "cannot_connect": "Impossible de se connecter à l'appareil.", + "invalid_auth": "Identifiant ou mot de passe invalide.", + "unknown": "Erreur inconnue." + }, + "step": { + "user": { + "data": { + "host": "Adresse IP", + "username": "Nom d'utilisateur", + "password": "Mot de passe", + "dualboot": "Est-ce que cet ordinateur est un dualboot Linux/Windows?", + "port": "Port", + "name": "Nom de l'appareil", + "mac": "Adresse MAC" + } + } + } + } +} \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..2f5660f --- /dev/null +++ b/utils.py @@ -0,0 +1,193 @@ +import logging + +import fabric2 +from fabric2 import Connection +_LOGGER = logging.getLogger(__name__) + +# _LOGGER.setLevel(logging.DEBUG) + + +def create_ssh_connection(host: str, username: str, password: str, port=22): + """Create an SSH connection to a host using a username and password specified in the config flow.""" + conf = fabric2.Config() + conf.run.hide = True + conf.run.warn = True + conf.warn = True + conf.sudo.password = password + conf.password = password + + connection = Connection( + host=host, user=username, port=port, connect_timeout=3, connect_kwargs={"password": password}, + config=conf + ) + + _LOGGER.info("CONNECTED SSH") + + return connection + + +def test_connection(connection: Connection): + """Test the connection to the host by running a simple command.""" + try: + connection.run('ls') + return True + except Exception: + return False + + +def is_unix_system(connection: Connection): + """Return a boolean based on get_operating_system result.""" + return get_operating_system(connection) == "Linux/Unix" + + +def get_operating_system_version(connection: Connection, is_unix=None): + """Return the running operating system name and version.""" + if is_unix is None: + is_unix = is_unix_system(connection) + + if is_unix: + return connection.run( + "lsb_release -a | awk '/Description/ {print $2, $3, $4}'" + ).stdout + else: + return connection.run( + 'for /f "tokens=1 delims=|" %i in (\'wmic os get Name ^| findstr /B /C:"Microsoft"\') do @echo %i' + ).stdout + + +def get_operating_system(connection: Connection): + """Return the running operating system type.""" + result = connection.run("uname") + if result.return_code == 0: + return "Linux/Unix" + else: + return "Windows/Other" + + +def shutdown_system(connection: Connection, is_unix=None): + """Shutdown the system.""" + + if is_unix is None: + is_unix = is_unix_system(connection) + + if is_unix: + # First method using shutdown command + result = connection.run("sudo shutdown -h now") + if result.return_code != 0: + # Try a second method using init command + result = connection.run("sudo init 0") + if result.return_code != 0: + # Try a third method using systemctl command + result = connection.run("sudo systemctl poweroff") + if result.return_code != 0: + _LOGGER.error("Cannot restart system, all methods failed.") + + else: + # First method using shutdown command + result = connection.run("shutdown /s /t 0") + if result.return_code != 0: + # Try a second method using init command + result = connection.run("wmic os where Primary=TRUE call Shutdown") + if result.return_code != 0: + _LOGGER.error("Cannot restart system, all methods failed.") + + connection.close() + + +def restart_system(connection: Connection, is_unix=None): + """Restart the system.""" + + if is_unix is None: + is_unix = is_unix_system(connection) + + if is_unix: + # First method using shutdown command + result = connection.run("sudo shutdown -r now") + if result.return_code != 0: + # Try a second method using init command + result = connection.run("sudo init 6") + if result.return_code != 0: + # Try a third method using systemctl command + result = connection.run("sudo systemctl reboot") + if result.return_code != 0: + _LOGGER.error("Cannot restart system, all methods failed.") + else: + # First method using shutdown command + result = connection.run("shutdown /r /t 0") + if result.return_code != 0: + # Try a second method using wmic command + result = connection.run("wmic os where Primary=TRUE call Reboot") + if result.return_code != 0: + _LOGGER.error("Cannot restart system, all methods failed.") + + +def sleep_system(connection: Connection, is_unix=None): + """Put the system to sleep.""" + + if is_unix is None: + is_unix = is_unix_system(connection) + + if is_unix: + # First method using systemctl command + result = connection.run("sudo systemctl suspend") + if result.return_code != 0: + # Try a second method using pm-suspend command + result = connection.run("sudo pm-suspend") + if result.return_code != 0: + _LOGGER.error("Cannot restart system, all methods failed.") + else: + # First method using shutdown command + result = connection.run("shutdown /h /t 0") + if result.return_code != 0: + # Try a second method using rundll32 command + result = connection.run("rundll32.exe powrprof.dll,SetSuspendState Sleep") + if result.return_code != 0: + _LOGGER.error("Cannot restart system, all methods failed.") + + +def get_windows_entry_in_grub(connection: Connection): + """ + Grabs the Windows entry name in GRUB. + Used later with grub-reboot to specify which entry to boot. + """ + result = connection.run("sudo awk -F \"'\" '/windows/ {print $2}' /boot/grub/grub.cfg") + + if result.return_code == 0: + _LOGGER.debug("Found Windows entry in grub : " + result.stdout.strip()) + else: + result = connection.run("sudo awk -F \"'\" '/windows/ {print $2}' /boot/grub2/grub.cfg") + if result.return_code == 0: + _LOGGER.debug("Found windows entry in grub2 : " + result.stdout.strip()) + else: + _LOGGER.error("Cannot find windows entry in grub") + return None + + # Check if the entry is valid + if result.stdout.strip() != "": + return result.stdout.strip() + else: + _LOGGER.error("Cannot find windows entry in grub") + return None + + +def restart_to_windows_from_linux(connection: Connection): + """Restart a running Linux system to Windows.""" + + if is_unix_system(connection): + windows_entry = get_windows_entry_in_grub(connection) + if windows_entry is not None: + # First method using grub-reboot command + result = connection.run(f"sudo grub-reboot \"{windows_entry}\"") + + if result.return_code != 0: + # Try a second method using grub2-reboot command + result = connection.run(f"sudo grub2-reboot \"{windows_entry}\"") + + # Restart system if successful grub(2)-reboot command + if result.return_code == 0: + _LOGGER.info("Rebooting to Windows") + restart_system(connection) + else: + _LOGGER.error("Cannot restart system to windows from linux, all methods failed.") + else: + _LOGGER.error("Cannot restart to Windows from Linux, system is not Linux/Unix")