move from homeassistant core to hacs addon

This commit is contained in:
mathieu 2023-04-04 13:44:07 +02:00
parent 49282b44ad
commit 51f3af16d7
11 changed files with 797 additions and 1 deletions

View File

@ -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.

66
__init__.py Normal file
View File

@ -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

107
config_flow.py Normal file
View File

@ -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."""

7
const.py Normal file
View File

@ -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"

20
manifest.json Normal file
View File

@ -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"
}

43
services.yaml Normal file
View File

@ -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

25
strings.json Normal file
View File

@ -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%]"
}
}
}

254
switch.py Normal file
View File

@ -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
),
}

25
translations/en.json Normal file
View File

@ -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"
}
}
}
}
}

25
translations/fr.json Normal file
View File

@ -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"
}
}
}
}
}

193
utils.py Normal file
View File

@ -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")