Compare commits

...

60 Commits
v1.0.0 ... main

Author SHA1 Message Date
f82826fc0a
improve structure and refactor 2024-10-19 12:38:26 +02:00
67df62ff91
improve ssh client class 2024-10-19 12:02:45 +02:00
59ff04775c
fixed sudo permission for some commands 2024-10-19 11:42:57 +02:00
08d9f78a35
improved ssh connections and reliability 2024-10-19 11:26:43 +02:00
591396895d
convert exception to debug log and check if on before update 2024-10-18 10:37:41 +02:00
daf2d6d63e Merge pull request 'fixed buggy ssh connections' (!1) from v2 into main
Reviewed-on: #1
2024-10-18 10:29:36 +02:00
94a65178c0
fixed buggy ssh connections 2024-10-17 22:11:10 +02:00
b4b0bead89
update readme 2024-08-30 11:31:13 +02:00
723b7b1284
improve to use less sudo commands 2024-08-30 11:19:48 +02:00
eaf434efc8
implement nircmd instlal and steam actions 2024-08-30 10:12:22 +02:00
767d877442
implement change audio config service 2024-08-30 09:55:51 +02:00
3e13d1ddb7
implement monitor config, rework of commands def and other fixes/improvements 2024-08-26 22:14:00 +02:00
8f2f03f134
fix strip error on CommandOutput and fix desktop env detect. linux 2024-08-26 20:51:33 +02:00
af870c63eb
move computer to submodule, add CommandOutput, remove useless getters, 2024-08-26 20:39:22 +02:00
3a3775f457
reformat, implement audio, monitors, bluetooth parse 2024-08-26 19:16:44 +02:00
3fc5fb948f
add audio and bluetooth commands, remove paramiko exception in config flow 2024-08-26 19:01:32 +02:00
bc16d06ac6
add gnome monitor parsing 2024-08-26 17:43:15 +02:00
e67e29facc
don't create new connection each time 2024-08-26 14:44:20 +02:00
7e6736c8b3
convert all to async (somehow it works ?? :)), update reqs and manifest 2024-08-26 14:29:09 +02:00
0f72986d2a
improve run_action and use fabric 2024-08-26 13:56:29 +02:00
ab2c8e0376
start working on massive refactor (v2) 2024-08-25 23:20:39 +02:00
e5b6af8e41
some small text refactor 2024-06-09 11:42:28 +02:00
b771739161
add bluetooth info to debug, fix bluetooth detections issues, add more info in error message 2024-06-09 11:34:58 +02:00
ad53165be2
improve install linux script 2024-06-09 11:31:24 +02:00
10324f542b
new bluetooth parsing method 2024-06-01 15:11:59 +02:00
d69ec9c352
disable blocking update if bluetooth devices retrieval fails 2024-06-01 14:44:00 +02:00
601258a6a3
fix for python3.12 and updates some packages 2024-03-17 12:27:13 +01:00
028769faac
fix wrong user when using sudo in auto script
Some checks failed
Validate with hassfest / validate (push) Failing after 1s
HACS Action / HACS Action (push) Failing after 3s
2024-01-01 19:56:50 +01:00
9d6f39d7ac
improved install script to allow for launching GUI apps from SSH
Some checks failed
Validate with hassfest / validate (push) Failing after 2s
HACS Action / HACS Action (push) Failing after 4s
2024-01-01 18:57:37 +01:00
f5747bd6d8
fix the resolution twice to fix gnome display bug
Some checks failed
Validate with hassfest / validate (push) Failing after 2s
HACS Action / HACS Action (push) Failing after 4s
2024-01-01 17:45:22 +01:00
ff256c71be
fixed bluetooth attr formatting
Some checks failed
Validate with hassfest / validate (push) Failing after 2s
HACS Action / HACS Action (push) Failing after 4s
2024-01-01 17:09:47 +01:00
fdb0e69f82
fixed bluetooth attr formatting
Some checks failed
Validate with hassfest / validate (push) Failing after 2s
HACS Action / HACS Action (push) Failing after 4s
2024-01-01 17:03:40 +01:00
aab4749b1d
add bluetooth devices detection
Some checks failed
Validate with hassfest / validate (push) Failing after 2s
HACS Action / HACS Action (push) Failing after 4s
2024-01-01 17:01:18 +01:00
ee725130e4
forget to remove some param from old wol
Some checks failed
Validate with hassfest / validate (push) Failing after 2s
HACS Action / HACS Action (push) Failing after 4s
2024-01-01 16:33:32 +01:00
197e453b43
some refactor and change device identifier to mac
Some checks failed
Validate with hassfest / validate (push) Failing after 1s
HACS Action / HACS Action (push) Failing after 3s
2024-01-01 16:27:22 +01:00
95bd521d06
fix entity not available when offline
Some checks failed
Validate with hassfest / validate (push) Failing after 1s
HACS Action / HACS Action (push) Failing after 3s
2024-01-01 16:24:00 +01:00
ffb1d60575
fix entity not available when offline
Some checks failed
Validate with hassfest / validate (push) Failing after 2s
HACS Action / HACS Action (push) Failing after 4s
2024-01-01 16:19:13 +01:00
7ced302e12
removed some leftovers from wol integration and rework of the service registring
Some checks failed
HACS Action / HACS Action (push) Failing after 6s
Validate with hassfest / validate (push) Failing after 1s
2023-12-31 17:30:27 +01:00
a3c195e792
fixed spacing in readme
Some checks failed
Validate with hassfest / validate (push) Failing after 1s
HACS Action / HACS Action (push) Failing after 4s
2023-12-31 16:53:26 +01:00
1b43683164
add icon and device registry 2023-12-31 16:52:30 +01:00
f74f1ce127
add readme header image
Some checks failed
Validate with hassfest / validate (push) Failing after 1s
HACS Action / HACS Action (push) Failing after 3s
2023-12-31 14:57:46 +01:00
e528c23c09
add readme
Some checks failed
Validate with hassfest / validate (push) Failing after 2s
HACS Action / HACS Action (push) Failing after 4s
2023-12-31 14:57:18 +01:00
c292bcd226
move docs to wiki and created auto scripts for configuration
Some checks failed
Validate with hassfest / validate (push) Failing after 2s
HACS Action / HACS Action (push) Failing after 5s
2023-12-31 14:15:49 +01:00
49932bc8e5
fix alphabetical order -_- in manifest
Some checks failed
Validate with hassfest / validate (push) Failing after 6s
HACS Action / HACS Action (push) Failing after 22s
2023-12-30 21:52:00 +01:00
f568cf2145
bump version in manifest 2023-12-30 21:50:21 +01:00
5434e4e5a1
fixes hacs validations 2023-12-30 21:49:15 +01:00
d6925cf287
add hacs actions 2023-12-30 21:39:30 +01:00
5bfaae0d9a
code refactor 2023-12-30 21:22:32 +01:00
8e749258af
add debug service and implemented monitors data parsing 2023-12-30 21:20:42 +01:00
f47e581fed
massive refactor and code improvements 2023-12-30 20:21:37 +01:00
1b4313eaa6
fixed optional parameters change_audio_config service 2023-12-30 20:14:24 +01:00
e55955d976
fix default output/input not set 2023-12-30 20:12:35 +01:00
ffac7ee2c0
fix optional parameters for change_audio_config 2023-12-30 19:20:28 +01:00
258ea2b9b6
add audio config service (not tested yet) 2023-12-30 18:45:14 +01:00
adc3ea0211
added nodered json example 2023-12-30 17:04:08 +01:00
9a23a6712a
added steam big picture service 2023-12-30 15:32:31 +01:00
b010429981
fix name typo 2023-12-30 13:41:23 +01:00
5e767f8b35
improved errors raising for services 2023-12-30 13:39:42 +01:00
b86d710ce3
improved monitors service entity selection and exceptions 2023-12-30 13:23:46 +01:00
c8831c9cd4
started implementing monitor config for windows 2023-12-30 12:59:25 +01:00
26 changed files with 1207 additions and 825 deletions

View File

@ -0,0 +1,69 @@
#!/bin/bash
# Retrieve the username of the person who invoked sudo
USER_BEHIND_SUDO=$(who am i | awk '{print $1}')
# Function to print colored text
print_colored() {
local color="$1"
local text="$2"
echo -e "${color}${text}\033[0m"
}
# Define colors
COLOR_RED="\033[0;31m"
COLOR_GREEN="\033[0;32m"
COLOR_YELLOW="\033[1;33m"
COLOR_BLUE="\033[0;34m"
# Ask for HomeAssistant IP
print_colored "$COLOR_BLUE" "Please enter your HomeAssistant local IP address (even if behind proxy, need LAN address):"
read -r HOMEASSISTANT_IP
# Enable SSH Server
print_colored "$COLOR_BLUE" "Enabling SSH Server..."
if command -v systemctl &> /dev/null; then
sudo systemctl enable --now sshd
print_colored "$COLOR_GREEN" "SSH Server enabled successfully."
else
print_colored "$COLOR_RED" "Systemctl not found. Please enable SSH manually."
fi
# Configure sudoers
print_colored "$COLOR_BLUE" "Configuring sudoers..."
echo -e "\n# Allow your user to execute specific commands without a password (for EasyComputerManager/HA)" | sudo tee -a /etc/sudoers > /dev/null
echo "$USER_BEHIND_SUDO ALL=(ALL) NOPASSWD: /sbin/shutdown, /sbin/init, /usr/sbin/pm-suspend, /usr/sbin/grub-reboot, /usr/sbin/grub2-reboot, /usr/bin/cat /etc/grub2.cfg, /usr/bin/cat /etc/grub.cfg, /usr/bin/systemctl poweroff, /usr/bin/systemctl reboot, /usr/bin/systemctl suspend" | sudo tee -a /etc/sudoers > /dev/null
print_colored "$COLOR_GREEN" "Sudoers file configured successfully."
# Firewall Configuration
print_colored "$COLOR_BLUE" "Configuring firewall..."
if command -v ufw &> /dev/null; then
sudo ufw allow 22
print_colored "$COLOR_GREEN" "Firewall configured to allow SSH."
else
print_colored "$COLOR_RED" "UFW not found. Please configure the firewall manually (if needed)."
fi
# Setup xhost for GUI apps
print_colored "$COLOR_BLUE" "Configuring persistent xhost for starting GUI apps (like Steam)..."
COMMANDS="xhost +$HOMEASSISTANT_IP; xhost +localhost"
DESKTOP_ENTRY_NAME="EasyComputerManager-AutoStart"
DESKTOP_ENTRY_PATH="/home/$USER_BEHIND_SUDO/.config/autostart/$DESKTOP_ENTRY_NAME.desktop"
# Create the desktop entry file for the Desktop Environment to autostart at login every reboot
cat > "$DESKTOP_ENTRY_PATH" <<EOF
[Desktop Entry]
Type=Application
Name=$DESKTOP_ENTRY_NAME
Exec=sh -c '$COMMANDS'
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
EOF
chmod +x "$DESKTOP_ENTRY_PATH"
print_colored "$COLOR_GREEN" "Desktop entry created at $DESKTOP_ENTRY_PATH."
print_colored "$COLOR_GREEN" "\nDone! Some features may require a reboot to work including:"
print_colored "$COLOR_YELLOW" " - Starting GUI apps from HomeAssistant"
print_colored "$COLOR_GREEN" "\nYou can now add your computer to HomeAssistant."
print_colored "$COLOR_RED" "\nWARNING : Don't forget to install these packages : gnome-monitor-config, pactl, bluetoothctl"

View File

@ -0,0 +1,30 @@
# Install OpenSSH Components
Write-Host "Installing OpenSSH Components..."
# Check if OpenSSH is installed
$opensshInstalled = Get-WindowsOptionalFeature -Online | Where-Object FeatureName -eq "OpenSSH.Client"
$opensshServerInstalled = Get-WindowsOptionalFeature -Online | Where-Object FeatureName -eq "OpenSSH.Server"
# Install OpenSSH Client and Server if not installed
if (!$opensshInstalled) {
Write-Host "Installing OpenSSH Client..."
Add-WindowsCapability -Online -Name OpenSSH.Client
}
if (!$opensshServerInstalled) {
Write-Host "Installing OpenSSH Server..."
Add-WindowsCapability -Online -Name OpenSSH.Server
}
# Start and set OpenSSH Server to Automatic
Write-Host "Configuring OpenSSH Server..."
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
# Confirm the Firewall rule is configured. It should be created automatically by setup. Run the following to verify
if (!(Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue | Select-Object Name, Enabled)) {
Write-Output "Firewall Rule 'OpenSSH-Server-In-TCP' does not exist, creating it..."
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
} else {
Write-Output "Firewall rule 'OpenSSH-Server-In-TCP' has been created and exists."
}

14
.github/workflows/hassfest.yaml vendored Normal file
View File

@ -0,0 +1,14 @@
name: Validate with hassfest
on:
push:
pull_request:
schedule:
- cron: '0 0 * * *'
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "home-assistant/actions/hassfest@master"

17
.github/workflows/validate.yaml vendored Normal file
View File

@ -0,0 +1,17 @@
name: HACS Action
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
hacs:
name: HACS Action
runs-on: "ubuntu-latest"
steps:
- name: HACS Action
uses: "hacs/action@main"
with:
category: "integration"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
.images/header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

113
HOWTO.md
View File

@ -1,113 +0,0 @@
# Quick documentation
## `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"
- **Input:** text
- `broadcast_address`
- **Name:** Broadcast address
- **Description:** Broadcast IP where to send the magic packet.
- **Example:** 192.168.255.255
- **Input:** text
- `broadcast_port`
- **Name:** Broadcast port
- **Description:** Port where to send the magic packet.
- **Default:** 9
- **Input:** number
- **Min:** 1
- **Max:** 65535
## `restart_to_windows_from_linux`
### Description
Restart the computer to Windows when running Linux using Grub.
### Target
- **Device Integration:** easy_computer_manage
## `restart_to_linux_from_windows`
### Description
Restart the computer to Linux when running Windows.
### Target
- **Device Integration:** easy_computer_manage
## `start_computer_to_windows`
### Description
Start the computer directly to Windows (boots to Linux, set grub reboot, then boots to Windows).
### Target
- **Device Integration:** easy_computer_manage
## `put_computer_to_sleep`
### Description
Put the computer to sleep.
### Target
- **Device Integration:** easy_computer_manage
## `restart_computer`
### Description
Restart the computer.
### Target
- **Device Integration:** easy_computer_manage
## `change_monitors_config`
### Description
Change monitors config.
### Fields
- `monitors_config`
- **Name:** Monitors config
- **Description:** Monitors config.
- **Required:** true
- **Selector:** object (yaml)
- **Example:**
```yaml
# Tip: You can use the command `gnome-monitor-config list` or `xrandr` to your monitors names and resolutions.
HDMI-1:
enabled: true
primary: true
position: [ 0, 0 ]
mode: 3840x2160@120.000
transform: normal
scale: 2
```
- `entity_id`
- **Name:** Entity ID
- **Description:** Entity ID of the device to change the monitors config.
- **Required:** true
- **Example:** "switch.my_computer"
- **Device Integration:** easy_computer_manage

101
README.md
View File

@ -1,69 +1,82 @@
# 🖧 Easy Computer Manager # 🖧 Easy Computer Manager
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/M4TH1EU/HA-EasyComputerManage?style=for-the-badge)](./releases/)
![img.png](.images/example1.png) ![img.png](.images/header.png)
## 🐧 Configure Linux-running computer to be managed by Home Assistant. Easy Computer Manager is a custom integration for Home Assistant that allows you to remotely manage various aspects of
your computer, such as sending Wake-On-LAN (WoL) packets, restarting the computer between different operating systems (
if dual-boot), adjusting audio configurations, changing monitor settings, and more.
### Enable the SSH server ## Features
Make sure to have a working SSH-server on your computer. I have only tested this integration with OpenSSH but YMMV. - Send Wake-On-LAN (WoL) packets to wake up a device.
- Restart the computer between Windows and Linux (dual-boot systems).
- Directly start the computer into Windows.
- Put the computer into sleep mode.
- Restart the computer.
- Modify monitor configurations.
- Start/Stop Steam Big Picture mode.
- Adjust audio settings (volume, mute, input, output).
- Display debug information for setup and troubleshooting.
On most system it can be enabled with the following commands : ## Installation
```bash ### Via HACS
sudo systemctl enable --now sshd
```
### Configure sudoers 1. Install [HACS](https://hacs.xyz/) if not already installed.
2. In Home Assistant, go to "HACS" in the sidebar.
3. Click on "Integrations."
4. Click on the three dots in the top right corner and select "Custom repositories."
5. Paste the following URL in the "Repo" field: https://github.com/M4TH1EU/HA-EasyComputerManager
6. Select "Integration" from the "Category" dropdown.
7. Click "Add."
8. Search for "easy_computer_manager" and click "Install."
We need to allow your user account to run specific sudo command without asking for the password so HomeAssistant can run ### Manually
them.
To do this, we need to edit sudoers file, run the following command ``visudo`` in a terminal and append the following
the to end of the file :
``` 1. Download the latest release from the [GitHub repository](https://github.com/M4TH1EU/HA-EasyComputerManager/).
# Allow your user user to execute shutdown, init, systemctl, pm-suspend, awk, grub-reboot, and grub2-reboot without a password 2. Extract the downloaded ZIP file.
username ALL=(ALL) NOPASSWD: /sbin/shutdown, /sbin/init, /usr/bin/systemctl, /usr/sbin/pm-suspend, /usr/bin/awk, /usr/sbin/grub-reboot, /usr/sbin/grub2-reboot 3. Copy the "custom_components/easy_computer_manager" directory to the "config/custom_components/" directory in your
``` Home Assistant instance.
*Note : It might be necessary to allow port 22 (ssh) in your firewall.* # Preparing your computer (required)
> [!CAUTION]
> Before adding your computer to Home Assistant, ensure that it is properly configured.
**⚠️ Be sure to replace username with your username.** **See wiki page [here](https://github.com/M4TH1EU/HA-EasyComputerManager/wiki/Prepare-your-computer).**
## 🪟 Configure Windows-running computer to be managed by Home Assistant. ## Add Computer to Home Assistant
To install the OpenSSH components: 1. In Home Assistant, go to "Configuration" in the sidebar.
2. Select "Integrations."
3. Click the "+" button to add a new integration.
4. Search for "Easy Computer Manager" and select it from the list.
5. Follow the on-screen instructions to configure the integration, providing details such as the IP address, mac-adress, username,
and password for the computer you want to add.
6. Once configured, click "Finish" to add the computer to Home Assistant.
1. Open Settings, select Apps, then select Optional Features. > [!NOTE]
2. Scan the list to see if the OpenSSH is already installed. If not, at the top of the page, select Add a feature, > If you are managing a dual-boot computer, ensure that the "Dual boot system" checkbox is enabled during the configuration.
then:
Find OpenSSH Client, then select Install (optional)
Find OpenSSH Server, then select Install
3. Once setup completes, return to Apps and Optional Features and confirm OpenSSH is listed.
4. Open the Services desktop app. (Select Start, type services.msc in the search box, and then select the Service app or
press ENTER.)
5. In the details pane, double-click OpenSSH SSH Server.
6. On the General tab, from the Startup type drop-down menu, select Automatic.
7. To start the service, select Start.
*Instructions ## Usage
from [Microsoft](https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse)*
*Note : It might be necessary to allow port 22 (ssh) in the Windows firewall altough it should be done automatically if After adding your computer to Home Assistant, you can use the provided services to manage it remotely. Explore the
following the instructions from above.* available services in the Home Assistant "Services" tab or use automations to integrate Easy Computer Manager into your
smart home setup.
## 🖧 Configure dual-boot (Windows/Linux) computer to be managed by Home Assistant. ## Services
A detailed list of available services and their parameters can be found in the wiki [here](https://github.com/M4TH1EU/HA-EasyComputerManager/wiki/Services).
To configure dual-boot computer, you need to configure both Windows and Linux, for this look at the 2 sections above. ## Troubleshooting
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.* If you encounter any issues during installation or configuration, refer to the troubleshooting section in
the [Wiki](./wiki) or seek assistance from the Home Assistant community.
## 🔑 Why not use SSH keys? ## Contributions
Well, simply because it would require the user to do some extra steps. Using the password, it's almost plug and play but Contributions are welcome! If you find any bugs or have suggestions for improvements, please open an issue or submit a
compromise the security a bit. pull request on the [GitHub repository](https://github.com/M4TH1EU/HA-EasyComputerManager).
_In the future, the option to use SSH keys might be added depending on user feedback._
Happy automating with Easy Computer Manager!

View File

@ -1,37 +1,35 @@
"""The Easy Dualboot Computer Manager integration.""" """The Easy Dualboot Computer Manager integration."""
# Some code is from the official wake_on_lan integration # Some snippets of code are from the official wake_on_lan integration (inspiration for this custom component)
from __future__ import annotations from __future__ import annotations
import logging import logging
from functools import partial from functools import partial
import homeassistant.helpers.config_validation as cv
import voluptuous as vol import voluptuous as vol
import wakeonlan import wakeonlan
import homeassistant.helpers.config_validation as cv
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from .const import DOMAIN, SERVICE_SEND_MAGIC_PACKET, SERVICE_CHANGE_MONITORS_CONFIG from .const import DOMAIN, SERVICE_SEND_MAGIC_PACKET, SERVICE_CHANGE_MONITORS_CONFIG
_LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA = vol.Schema( WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA = vol.Schema({
{ vol.Required(CONF_MAC): cv.string,
vol.Required(CONF_MAC): cv.string, vol.Optional(CONF_BROADCAST_ADDRESS): cv.string,
vol.Optional(CONF_BROADCAST_ADDRESS): cv.string, vol.Optional(CONF_BROADCAST_PORT): cv.port,
vol.Optional(CONF_BROADCAST_PORT): cv.port, })
}
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the wake on LAN component.""" """Set up the Easy Dualboot Computer Manager integration."""
async def send_magic_packet(call: ServiceCall) -> None: async def send_magic_packet(call: ServiceCall) -> None:
"""Send magic packet to wake up a device.""" """Send a magic packet to wake up a device."""
mac_address = call.data.get(CONF_MAC) mac_address = call.data.get(CONF_MAC)
broadcast_address = call.data.get(CONF_BROADCAST_ADDRESS) broadcast_address = call.data.get(CONF_BROADCAST_ADDRESS)
broadcast_port = call.data.get(CONF_BROADCAST_PORT) broadcast_port = call.data.get(CONF_BROADCAST_PORT)
@ -42,8 +40,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if broadcast_port is not None: if broadcast_port is not None:
service_kwargs["port"] = broadcast_port service_kwargs["port"] = broadcast_port
_LOGGER.info( LOGGER.info(
"Send magic packet to mac %s (broadcast: %s, port: %s)", "Sending magic packet to MAC %s (broadcast: %s, port: %s)",
mac_address, mac_address,
broadcast_address, broadcast_address,
broadcast_port, broadcast_port,
@ -53,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs)
) )
# Register the wake on lan service
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_SEND_MAGIC_PACKET, SERVICE_SEND_MAGIC_PACKET,
@ -60,17 +59,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
schema=WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA, schema=WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA,
) )
hass.async_create_task( await hass.config_entries.async_forward_entry_setups(entry, ["switch"])
hass.config_entries.async_forward_entry_setup(
entry, "switch"
)
)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload the Easy Dualboot Computer Manager integration."""
return await hass.config_entries.async_forward_entry_unload( return await hass.config_entries.async_forward_entry_unload(
entry, "switch" entry, "switch"
) )

View File

@ -0,0 +1,208 @@
import asyncio
from typing import Optional, Dict, Any
from wakeonlan import send_magic_packet
from custom_components.easy_computer_manager import const, LOGGER
from custom_components.easy_computer_manager.computer.common import OSType, CommandOutput
from custom_components.easy_computer_manager.computer.formatter import format_gnome_monitors_args, format_pactl_commands
from custom_components.easy_computer_manager.computer.parser import parse_gnome_monitors_output, parse_pactl_output, \
parse_bluetoothctl
from custom_components.easy_computer_manager.computer.ssh_client import SSHClient
class Computer:
def __init__(self, host: str, mac: str, username: str, password: str, port: int = 22,
dualboot: bool = False) -> None:
"""Initialize the Computer object."""
self.initialized = False # used to avoid duplicated ssh connections
self.host = host
self.mac = mac
self.username = username
self._password = password
self.port = port
self.dualboot = dualboot
self.operating_system: Optional[OSType] = None
self.operating_system_version: Optional[str] = None
self.desktop_environment: Optional[str] = None
self.windows_entry_grub: Optional[str] = None
self.monitors_config: Optional[Dict[str, Any]] = None
self.audio_config: Dict[str, Optional[Dict]] = {}
self.bluetooth_devices: Dict[str, Any] = {}
self._connection: SSHClient = SSHClient(host, username, password, port)
asyncio.create_task(self._connection.connect(computer=self))
async def update(self, state: Optional[bool] = True, timeout: Optional[int] = 2) -> None:
"""Update computer details."""
if not state or not await self.is_on():
LOGGER.debug("Computer is off, skipping update")
return
# Ensure connection is established before updating
await self._ensure_connection_alive(timeout)
# Update tasks
await asyncio.gather(
self._update_operating_system(),
self._update_operating_system_version(),
self._update_desktop_environment(),
self._update_windows_entry_grub(),
self._update_monitors_config(),
self._update_audio_config(),
self._update_bluetooth_devices()
)
async def _ensure_connection_alive(self, timeout: int) -> None:
"""Ensure the SSH connection is alive, reconnect if necessary."""
for _ in range(timeout * 4):
if self._connection.is_connection_alive():
return
await asyncio.sleep(0.25)
if not self._connection.is_connection_alive():
LOGGER.debug(f"Reconnecting to {self.host}")
await self._connection.connect()
if not self._connection.is_connection_alive():
LOGGER.debug(f"Failed to connect to {self.host} after timeout={timeout}s")
raise ConnectionError("SSH connection could not be re-established")
async def _update_operating_system(self) -> None:
"""Update the operating system information."""
self.operating_system = await self._detect_operating_system()
async def _update_operating_system_version(self) -> None:
"""Update the operating system version."""
self.operating_system_version = (await self.run_action("operating_system_version")).output
async def _update_desktop_environment(self) -> None:
"""Update the desktop environment information."""
self.desktop_environment = (await self.run_action("desktop_environment")).output.lower()
async def _update_windows_entry_grub(self) -> None:
"""Update Windows entry in GRUB (if applicable)."""
self.windows_entry_grub = (await self.run_action("get_windows_entry_grub")).output
async def _update_monitors_config(self) -> None:
"""Update monitors configuration."""
if self.operating_system == OSType.LINUX:
output = (await self.run_action("get_monitors_config")).output
self.monitors_config = parse_gnome_monitors_output(output)
# TODO: Implement for Windows if needed
async def _update_audio_config(self) -> None:
"""Update audio configuration."""
speakers_output = (await self.run_action("get_speakers")).output
microphones_output = (await self.run_action("get_microphones")).output
if self.operating_system == OSType.LINUX:
self.audio_config = parse_pactl_output(speakers_output, microphones_output)
# TODO: Implement for Windows
async def _update_bluetooth_devices(self) -> None:
"""Update Bluetooth devices list."""
if self.operating_system == OSType.LINUX:
self.bluetooth_devices = parse_bluetoothctl(await self.run_action("get_bluetooth_devices"))
# TODO: Implement for Windows
async def _detect_operating_system(self) -> OSType:
"""Detect the operating system by running a uname command."""
result = await self.run_manually("uname")
return OSType.LINUX if result.successful() else OSType.WINDOWS
async def is_on(self, timeout: int = 1) -> bool:
"""Check if the computer is on by pinging it."""
ping_cmd = ["ping", "-c", "1", "-W", str(timeout), str(self.host)]
proc = await asyncio.create_subprocess_exec(
*ping_cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL
)
await proc.communicate()
return proc.returncode == 0
async def start(self) -> None:
"""Start the computer using Wake-on-LAN."""
send_magic_packet(self.mac)
async def shutdown(self) -> None:
"""Shutdown the computer."""
await self.run_action("shutdown")
async def restart(self, from_os: Optional[OSType] = None, to_os: Optional[OSType] = None) -> None:
"""Restart the computer."""
await self.run_action("restart")
async def put_to_sleep(self) -> None:
"""Put the computer to sleep."""
await self.run_action("sleep")
async def set_monitors_config(self, monitors_config: Dict[str, Any]) -> None:
"""Set monitors configuration."""
if self.is_linux() and self.desktop_environment == 'gnome':
await self.run_action("set_monitors_config", params={"args": format_gnome_monitors_args(monitors_config)})
async def set_audio_config(self, volume: Optional[int] = None, mute: Optional[bool] = None,
input_device: Optional[str] = None, output_device: Optional[str] = None) -> None:
"""Set audio configuration."""
if self.is_linux() and self.desktop_environment == 'gnome':
pactl_commands = format_pactl_commands(self.audio_config, volume, mute, input_device, output_device)
for command in pactl_commands:
await self.run_action("set_audio_config", params={"args": command})
async def install_nircmd(self) -> None:
"""Install NirCmd tool (Windows specific)."""
install_path = f"C:\\Users\\{self.username}\\AppData\\Local\\EasyComputerManager"
await self.run_action("install_nircmd", params={
"download_url": "https://www.nirsoft.net/utils/nircmd.zip",
"install_path": install_path
})
async def steam_big_picture(self, action: str) -> None:
"""Start, stop, or exit Steam Big Picture mode."""
await self.run_action(f"{action}_steam_big_picture")
async def run_action(self, id: str, params: Optional[Dict[str, Any]] = None,
raise_on_error: bool = False) -> CommandOutput:
"""Run a predefined action via SSH."""
params = params or {}
action = const.ACTIONS.get(id)
if not action:
LOGGER.error(f"Action {id} not found.")
return CommandOutput("", 1, "", "Action not found")
if not self.operating_system:
self.operating_system = await self._detect_operating_system()
os_commands = action.get(self.operating_system.lower())
if not os_commands:
raise ValueError(f"Action {id} not supported for OS: {self.operating_system}")
commands = os_commands if isinstance(os_commands, list) else os_commands.get("commands",
[os_commands.get("command")])
required_params = []
if "params" in os_commands:
required_params = os_commands.get("params", [])
# Validate parameters
if sorted(required_params) != sorted(params.keys()):
raise ValueError(f"Invalid/missing parameters for action: {id}")
for command in commands:
for param, value in params.items():
command = command.replace(f"%{param}%", str(value))
result = await self.run_manually(command)
if result.successful():
return result
elif raise_on_error:
raise ValueError(f"Command failed: {command}")
return result
async def run_manually(self, command: str) -> CommandOutput:
"""Run a custom command manually via SSH."""
return await self._connection.execute_command(command)

View File

@ -0,0 +1,18 @@
from enum import Enum
class OSType(str, Enum):
WINDOWS = "Windows"
LINUX = "Linux"
MACOS = "MacOS"
class CommandOutput:
def __init__(self, command: str, return_code: int, output: str, error: str) -> None:
self.command = command
self.return_code = return_code
self.output = output.strip()
self.error = error.strip()
def successful(self) -> bool:
return self.return_code == 0

View File

@ -0,0 +1,62 @@
def format_gnome_monitors_args(monitors_config: dict):
args = []
monitors_config = monitors_config.get('monitors_config', {})
for monitor, settings in monitors_config.items():
if settings.get('enabled', False):
args.extend(['-LpM' if settings.get('primary', False) else '-LM', monitor])
if 'position' in settings:
args.extend(['-x', str(settings["position"][0]), '-y', str(settings["position"][1])])
if 'mode' in settings:
args.extend(['-m', settings["mode"]])
if 'scale' in settings:
args.extend(['-s', str(settings["scale"])])
if 'transform' in settings:
args.extend(['-t', settings["transform"]])
return ' '.join(args)
def format_pactl_commands(current_config: {}, volume: int, mute: bool, input_device: str = "@DEFAULT_SOURCE@",
output_device: str = "@DEFAULT_SINK@"):
"""Change audio configuration on the host system."""
commands = []
def get_device_id(device_type, user_device):
for device in current_config[device_type]:
if device['description'] == user_device:
return device['name']
return user_device
# Set default sink and source if not specified
if not output_device:
output_device = "@DEFAULT_SINK@"
if not input_device:
input_device = "@DEFAULT_SOURCE@"
# Set default sink if specified
if output_device and output_device != "@DEFAULT_SINK@":
output_device = get_device_id('sinks', output_device)
commands.append(f"set-default-sink {output_device}")
# Set default source if specified
if input_device and input_device != "@DEFAULT_SOURCE@":
input_device = get_device_id('sources', input_device)
commands.append(f"set-default-source {input_device}")
# Set sink volume if specified
if volume is not None:
commands.append(f"set-sink-volume {output_device} {volume}%")
# Set sink and source mute status if specified
if mute is not None:
commands.append(f"set-sink-mute {output_device} {'yes' if mute else 'no'}")
commands.append(f"set-source-mute {input_device} {'yes' if mute else 'no'}")
return commands

View File

@ -0,0 +1,168 @@
import re
from custom_components.easy_computer_manager import LOGGER
from custom_components.easy_computer_manager.computer import CommandOutput
def parse_gnome_monitors_output(config: str) -> list:
"""
Parse the GNOME monitors configuration.
:param config:
The output of the gnome-monitor-config list command.
:type config: str
:returns: list
The parsed monitors configuration.
"""
monitors = []
current_monitor = None
for line in config.split('\n'):
monitor_match = re.match(r'^Monitor \[ (.+?) \] (ON|OFF)$', line)
if monitor_match:
if current_monitor:
monitors.append(current_monitor)
source, status = monitor_match.groups()
current_monitor = {'source': source, 'status': status, 'names': [], 'resolutions': []}
elif current_monitor:
display_name_match = re.match(r'^\s+display-name: (.+)$', line)
resolution_match = re.match(r'^\s+(\d+x\d+@\d+(?:\.\d+)?).*$', line)
if display_name_match:
current_monitor['names'].append(display_name_match.group(1).replace('"', ''))
elif resolution_match:
# Don't include resolutions under 1280x720
if int(resolution_match.group(1).split('@')[0].split('x')[0]) >= 1280:
# If there are already resolutions in the list, check if the framerate between the last is >1
if len(current_monitor['resolutions']) > 0:
last_resolution = current_monitor['resolutions'][-1]
last_resolution_size = last_resolution.split('@')[0]
this_resolution_size = resolution_match.group(1).split('@')[0]
# Only truncate some framerates if the resolution are the same
if last_resolution_size == this_resolution_size:
last_resolution_framerate = float(last_resolution.split('@')[1])
this_resolution_framerate = float(resolution_match.group(1).split('@')[1])
# If the difference between the last resolution framerate and this one is >1, ignore it
if last_resolution_framerate - 1 > this_resolution_framerate:
current_monitor['resolutions'].append(resolution_match.group(1))
else:
# If the resolution is different, this adds the new resolution
# to the list without truncating
current_monitor['resolutions'].append(resolution_match.group(1))
else:
# This is the first resolution, add it to the list
current_monitor['resolutions'].append(resolution_match.group(1))
if current_monitor:
monitors.append(current_monitor)
return monitors
def parse_pactl_output(config_speakers: str, config_microphones: str) -> dict[str, list]:
"""
Parse the pactl audio configuration.
:param config_speakers:
The output of the pactl list sinks command.
:param config_microphones:
The output of the pactl list sources command.
:type config_speakers: str
:type config_microphones: str
:returns: dict
The parsed audio configuration.
"""
config = {'speakers': [], 'microphones': []}
def parse_device_info(lines, device_type):
devices = []
current_device = {}
for line in lines:
if line.startswith(f"{device_type} #"):
if current_device and "Monitor" not in current_device['description']:
devices.append(current_device)
current_device = {'id': int(re.search(r'#(\d+)', line).group(1))}
elif line.startswith(" Name:"):
current_device['name'] = line.split(":")[1].strip()
elif line.startswith(" State:"):
current_device['state'] = line.split(":")[1].strip()
elif line.startswith(" Description:"):
current_device['description'] = line.split(":")[1].strip()
if current_device:
devices.append(current_device)
return devices
config['speakers'] = parse_device_info(config_speakers.split('\n'), 'Sink')
config['microphones'] = parse_device_info(config_microphones.split('\n'), 'Source')
return config
def parse_bluetoothctl(command: CommandOutput, connected_devices_only: bool = True,
return_as_string: bool = False) -> list | str:
"""Parse the bluetoothctl info command.
:param command:
The command output.
:param connected_devices_only:
Only return connected devices.
Will return all devices, connected or not, if False.
:type command: :class: CommandOutput
:type connected_devices_only: bool
:returns: str | list
The parsed bluetooth devices.
"""
if not command.successful():
if command.output.__contains__("Missing device address argument"): # Means no devices are connected
return "" if return_as_string else []
else:
LOGGER.warning(f"Cannot retrieve bluetooth devices, make sure bluetoothctl is installed")
return "" if return_as_string else []
devices = []
current_device = None
for line in command.output.split('\n'):
if line.startswith('Device'):
if current_device is not None:
devices.append({
"address": current_device,
"name": current_name,
"connected": current_connected
})
current_device = line.split()[1]
current_name = None
current_connected = None
elif 'Name:' in line:
current_name = line.split(': ', 1)[1]
elif 'Connected:' in line:
current_connected = line.split(': ')[1] == 'yes'
# Add the last device if any
if current_device is not None:
devices.append({
"address": current_device,
"name": current_name,
"connected": current_connected
})
if connected_devices_only:
devices = [device for device in devices if device["connected"] == True]
return devices

View File

@ -0,0 +1,104 @@
import asyncio
from typing import Optional
import paramiko
from custom_components.easy_computer_manager import LOGGER
from custom_components.easy_computer_manager.computer import CommandOutput
class SSHClient:
def __init__(self, host: str, username: str, password: Optional[str] = None, port: int = 22):
self.host = host
self.username = username
self._password = password
self.port = port
self._connection: Optional[paramiko.SSHClient] = None
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(self, exc_type, exc_value, traceback):
self.disconnect()
async def connect(self, retried: bool = False, computer: Optional['Computer'] = None) -> None:
"""Open an SSH connection using Paramiko asynchronously."""
if self.is_connection_alive():
LOGGER.debug(f"Connection to {self.host} is already active.")
return
self.disconnect() # Ensure any previous connection is closed
loop = asyncio.get_running_loop()
client = paramiko.SSHClient()
# Set missing host key policy to automatically accept unknown host keys
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
# Offload the blocking connect call to a thread
await loop.run_in_executor(None, self._blocking_connect, client)
self._connection = client
LOGGER.debug(f"Connected to {self.host}")
except (OSError, paramiko.SSHException) as exc:
LOGGER.debug(f"Failed to connect to {self.host}: {exc}")
if not retried:
LOGGER.debug(f"Retrying connection to {self.host}...")
await self.connect(retried=True) # Retry only once
finally:
if computer is not None and hasattr(computer, "initialized"):
computer.initialized = True
def disconnect(self) -> None:
"""Close the SSH connection."""
if self._connection:
self._connection.close()
LOGGER.debug(f"Disconnected from {self.host}")
self._connection = None
def _blocking_connect(self, client: paramiko.SSHClient):
"""Perform the blocking SSH connection using Paramiko."""
client.connect(
hostname=self.host,
username=self.username,
password=self._password,
port=self.port,
look_for_keys=False, # Set this to True if using private keys
allow_agent=False
)
async def execute_command(self, command: str) -> CommandOutput:
"""Execute a command on the SSH server asynchronously."""
if not self.is_connection_alive():
LOGGER.debug(f"Connection to {self.host} is not alive. Reconnecting...")
await self.connect()
try:
# Offload command execution to avoid blocking
loop = asyncio.get_running_loop()
stdin, stdout, stderr = await loop.run_in_executor(None, self._connection.exec_command, command)
exit_status = stdout.channel.recv_exit_status()
return CommandOutput(command, exit_status, stdout.read().decode(), stderr.read().decode())
except (paramiko.SSHException, EOFError) as exc:
LOGGER.error(f"Failed to execute command on {self.host}: {exc}")
return CommandOutput(command, -1, "", "")
def is_connection_alive(self) -> bool:
"""Check if the SSH connection is still alive."""
if self._connection is None:
return False
try:
transport = self._connection.get_transport()
transport.send_ignore()
self._connection.exec_command('ls', timeout=1)
return True
except Exception as e:
return False

View File

@ -0,0 +1,40 @@
async def format_debug_information(computer: 'Computer'): # importing Computer causes circular import (how to fix?)
"""Return debug information about the host system."""
data = {
'os': {
'name': computer.operating_system,
'version': computer.operating_system_version,
'desktop_environment': computer.desktop_environment
},
'connection': {
'host': computer.host,
'mac': computer.mac,
'username': computer.username,
'port': computer.port,
'dualboot': computer.dualboot,
'is_on': await computer.is_on(),
'is_connected': computer._connection.is_connection_alive()
},
'grub': {
'windows_entry': computer.windows_entry_grub
},
'audio': {
'speakers': computer.audio_config.get('speakers'),
'microphones': computer.audio_config.get('microphones')
},
'monitors': computer.monitors_config,
'bluetooth_devices': computer.bluetooth_devices
}
return data
def get_bluetooth_devices_as_str(computer: 'Computer') -> str:
"""Return the bluetooth devices as a string."""
devices = computer.bluetooth_devices
if not devices:
return ""
return "; ".join([f"{device['name']} ({device['address']})" for device in devices])

View File

@ -5,11 +5,10 @@ import logging
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
from paramiko.ssh_exception import AuthenticationException
from homeassistant import config_entries, exceptions from homeassistant import config_entries, exceptions
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import utils
from .computer import Computer
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -40,6 +39,8 @@ class Hub:
self._name = host self._name = host
self._id = host.lower() self._id = host.lower()
self.computer = Computer(host, "", username, password, port)
@property @property
def hub_id(self) -> str: def hub_id(self) -> str:
"""ID for dummy.""" """ID for dummy."""
@ -48,9 +49,11 @@ class Hub:
async def test_connection(self) -> bool: async def test_connection(self) -> bool:
"""Test connectivity to the computer is OK.""" """Test connectivity to the computer is OK."""
try: try:
return utils.test_connection( # TODO: check if reachable
utils.create_ssh_connection(self._host, self._username, self._password, self._port)) _LOGGER.info("Testing connection to %s", self._host)
except AuthenticationException: return True
except Exception:
return False return False
@ -63,8 +66,8 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]:
hub = Hub(hass, data["host"], data["username"], data["password"], data["port"]) hub = Hub(hass, data["host"], data["username"], data["password"], data["port"])
result = await hub.test_connection() _LOGGER.info("Validating configuration")
if not result: if not await hub.test_connection():
raise CannotConnect raise CannotConnect
return {"title": data["host"]} return {"title": data["host"]}
@ -81,23 +84,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
try: try:
info = await validate_input(self.hass, user_input) info = await validate_input(self.hass, user_input)
return self.async_create_entry(title=info["title"], data=user_input) return self.async_create_entry(title=info["title"], data=user_input)
except AuthenticationException: except (CannotConnect, InvalidHost) as ex:
errors["host"] = "cannot_connect" errors["base"] = str(ex)
except CannotConnect: except Exception as ex: # pylint: disable=broad-except
errors["base"] = "cannot_connect" _LOGGER.exception("Unexpected exception: %s", ex)
except InvalidHost:
errors["host"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
# If there is no user input or there were errors, show the form again, including any errors that were found return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors=errors)
# with the input.
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
class CannotConnect(exceptions.HomeAssistantError): class CannotConnect(exceptions.HomeAssistantError):

View File

@ -8,3 +8,90 @@ SERVICE_PUT_COMPUTER_TO_SLEEP = "put_computer_to_sleep"
SERVICE_START_COMPUTER_TO_WINDOWS = "start_computer_to_windows" SERVICE_START_COMPUTER_TO_WINDOWS = "start_computer_to_windows"
SERVICE_RESTART_COMPUTER = "restart_computer" SERVICE_RESTART_COMPUTER = "restart_computer"
SERVICE_CHANGE_MONITORS_CONFIG = "change_monitors_config" SERVICE_CHANGE_MONITORS_CONFIG = "change_monitors_config"
SERVICE_STEAM_BIG_PICTURE = "steam_big_picture"
SERVICE_CHANGE_AUDIO_CONFIG = "change_audio_config"
SERVICE_DEBUG_INFO = "debug_info"
ACTIONS = {
"operating_system": {
"linux": ["uname"]
},
"operating_system_version": {
"windows": ['for /f "tokens=1 delims=|" %i in (\'wmic os get Name ^| findstr /B /C:"Microsoft"\') do @echo %i'],
"linux": ["awk -F'=' '/^NAME=|^VERSION=/{gsub(/\"/, \"\", $2); printf $2\" \"}\' /etc/os-release && echo", "lsb_release -a | awk '/Description/ {print $2, $3, $4}'"]
},
"desktop_environment": {
"linux": ["for session in $(ls /usr/bin/*session 2>/dev/null); do basename $session | sed 's/-session//'; done | grep -E 'gnome|kde|xfce|mate|lxde|cinnamon|budgie|unity' | head -n 1"],
"windows": ["echo Windows"]
},
"shutdown": {
"windows": ["shutdown /s /t 0", "wmic os where Primary=TRUE call Shutdown"],
"linux": ["sudo /sbin/shutdown -h now", "sudo /sbin/init 0", "sudo /usr/bin/systemctl poweroff"]
},
"restart": {
"windows": ["shutdown /r /t 0", "wmic os where Primary=TRUE call Reboot"],
"linux": ["sudo /sbin/shutdown -r now", "sudo /sbin/init 6", "sudo /usr/bin/systemctl reboot"]
},
"sleep": {
"windows": ["shutdown /h /t 0", "rundll32.exe powrprof.dll,SetSuspendState Sleep"],
"linux": ["sudo /usr/bin/systemctl suspend", "sudo /usr/sbin/pm-suspend"]
},
"get_windows_entry_grub": {
"linux": ["sudo /usr/bin/cat /etc/grub2.cfg | awk -F \"'\" '/windows/ {print $2}'",
"sudo /usr/bin/cat /etc/grub.cfg | awk -F \"'\" '/windows/ {print $2}'"]
},
"set_grub_entry": {
"linux": {
"commands": ["sudo /usr/sbin/grub-reboot %grub-entry%", "sudo /usr/sbin/grub2-reboot %grub-entry%"],
"params": ["grub-entry"],
}
},
"get_monitors_config": {
"linux": ["gnome-monitor-config list"]
},
"set_monitors_config": {
"linux": {
"gnome": {
"command": "gnome-monitor-config set %args%",
"params": ["args"]
}
}
},
"get_speakers": {
"linux": ["LANG=en_US.UTF-8 pactl list sinks"]
},
"get_microphones": {
"linux": ["LANG=en_US.UTF-8 pactl list sources"]
},
"set_audio_config": {
"linux": {
"command": "LANG=en_US.UTF-8 pactl %args%",
"params": ["args"]
}
},
"get_bluetooth_devices": {
"linux": {
"command": "bluetoothctl info",
"raise_on_error": False,
}
},
"install_nirmcd": {
"windows": {
"command": "powershell -Command \"Invoke-WebRequest -Uri %download_url% -OutFile %install_path%\\nircmd.zip -UseBasicParsing; Expand-Archive %install_path%\\nircmd.zip -DestinationPath %install_path%; Remove-Item %install_path%\\nircmd.zip\"",
"params": ["download_url", "install_path"]
}
},
"start_steam_big_picture": {
"linux": "export WAYLAND_DISPLAY=wayland-0; export DISPLAY=:0; steam -bigpicture &",
"windows": "start steam://open/bigpicture"
},
"stop_steam_big_picture": {
"linux": "export WAYLAND_DISPLAY=wayland-0; export DISPLAY=:0; steam -shutdown &",
"windows": "C:\\Program Files (x86)\\Steam\\steam.exe -shutdown"
},
"exit_steam_big_picture": {
"linux": "", # TODO: find a way to exit steam big picture
"windows": "nircmd win close title \"Steam Big Picture Mode\""
},
}

View File

@ -1,20 +1,17 @@
{ {
"domain": "easy_computer_manager", "domain": "easy_computer_manager",
"name": "Easy Dualboot Computer Manager", "name": "Easy Computer Manager",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/easy_computer_manager",
"requirements": [
"wakeonlan==2.1.0",
"fabric2==2.7.1"
],
"ssdp": [],
"zeroconf": [],
"homekit": {},
"dependencies": [],
"codeowners": [ "codeowners": [
"@M4TH1EU", "@M4TH1EU"
"@ntilley905"
], ],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/M4TH1EU/HA-EasyComputerManager/README.md",
"iot_class": "local_polling", "iot_class": "local_polling",
"version": "0.0.1" "issue_tracker": "https://github.com/M4TH1EU/HA-EasyComputerManager/issues",
"requirements": [
"wakeonlan==3.1.0",
"asyncssh==2.16.0"
],
"version": "2.0.0"
} }

View File

@ -1,82 +1,149 @@
send_magic_packet: send_magic_packet:
name: Send magic packet name: Send Magic Packet
description: Send a 'magic packet' to wake up a device with 'Wake-On-LAN' capabilities. description: Send a 'magic packet' to wake up a device with 'Wake-On-LAN' capabilities.
fields: fields:
mac: mac:
name: MAC address name: MAC Address
description: MAC address of the device to wake up. description: MAC address of the target device.
required: true required: true
example: "aa:bb:cc:dd:ee:ff" example: "aa:bb:cc:dd:ee:ff"
selector: selector:
text: text:
broadcast_address: broadcast_address:
name: Broadcast address name: Broadcast Address
description: Broadcast IP where to send the magic packet. description: Broadcast IP to send the magic packet.
example: 192.168.255.255 example: 192.168.255.255
selector: selector:
text: text:
broadcast_port: broadcast_port:
name: Broadcast port name: Broadcast Port
description: Port where to send the magic packet. description: Port to send the magic packet.
default: 9 default: 9
selector: selector:
number: number:
min: 1 min: 1
max: 65535 max: 65535
restart_to_windows_from_linux: restart_to_windows_from_linux:
name: Restart to Windows from Linux name: Restart to Windows from Linux
description: Restart the computer to Windows when running Linux using Grub. description: Restart the computer to Windows while running Linux using Grub.
target: target:
device: device:
integration: easy_computer_manager integration: easy_computer_manager
restart_to_linux_from_windows: restart_to_linux_from_windows:
name: Restart to Linux from Windows name: Restart to Linux from Windows
description: Restart the computer to Linux when running Windows. description: Restart the computer to Linux while running Windows.
target: target:
device: device:
integration: easy_computer_manager integration: easy_computer_manager
start_computer_to_windows: start_computer_to_windows:
name: 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). description: Directly start the computer into Windows (boot to Linux, set Grub reboot, then boot to Windows).
target: target:
device: device:
integration: easy_computer_manager integration: easy_computer_manager
put_computer_to_sleep: put_computer_to_sleep:
name: Put computer to sleep name: Put Computer to Sleep
description: Put the computer to sleep. description: Put the computer into sleep mode.
target: target:
device: device:
integration: easy_computer_manager integration: easy_computer_manager
restart_computer: restart_computer:
name: Restart name: Restart Computer
description: Restart the computer. description: Restart the computer.
target: target:
device: device:
integration: easy_computer_manager integration: easy_computer_manager
change_monitors_config: change_monitors_config:
name: Change monitors config name: Change Monitors Configuration
description: Change monitors config. description: Modify monitors configuration.
target:
entity:
integration: easy_computer_manager
domain: switch
fields: fields:
entity_id:
name: Entity ID
description: Entity ID of the device to change monitors config.
required: true
example: switch.my_computer
selector:
entity:
integration: easy_computer_manager
domain: switch
monitors_config: monitors_config:
name: Monitors config name: Monitors Configuration
description: Monitors config. description: Monitors configuration details.
required: true required: true
example: | example: |
HDMI-1: HDMI-1:
enabled: true enabled: true
primary: true primary: true
position: [ 0, 0 ] position: [0, 0]
mode: 3840x2160@120.000 mode: 3840x2160@120.000
transform: normal transform: normal
scale: 2 scale: 2
selector: selector:
object: object:
steam_big_picture:
name: Start/Stop Steam Big Picture
description: Initiate or terminate Steam Big Picture mode.
target:
entity:
integration: easy_computer_manager
domain: switch
fields:
action:
name: Action
description: Choose whether to start, stop, or return to the desktop Steam UI.
required: true
example: "start"
selector:
select:
options:
- label: Start
value: start
- label: Stop
value: stop
- label: Exit and return to desktop Steam UI
value: exit
change_audio_config:
name: Change Audio Configuration
description: Adjust audio settings (volume, mute, input, output).
target:
entity:
integration: easy_computer_manager
domain: switch
fields:
volume:
name: Volume
description: Set the desired volume level.
example: 50
selector:
number:
min: 0
max: 100
mute:
name: Mute
description: Mute the audio.
example: true
selector:
boolean:
input_device:
name: Input Device
description: Specify the ID/name/description of the input device.
example: "Kraken 7.1 Chroma Stereo Analog"
selector:
text:
output_device:
name: Output Device
description: Specify the ID/name/description of the output device.
example: "Starship/Matisse HD Audio Controller Stereo Analog"
selector:
text:
debug_info:
name: Debug Information
description: Display debug information to help with setup and troubleshooting. You can use this data (such as monitor resolutions, audio device names/IDs, etc.) with others services such as change_audio_config or change_monitors_config
target:
entity:
integration: easy_computer_manager
domain: switch

View File

@ -1,139 +1,81 @@
# Some code is from the official wake_on_lan integration
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging from typing import Any, Dict
import subprocess as sp
from typing import Any
import voluptuous as vol import voluptuous as vol
import wakeonlan from homeassistant.components.switch import SwitchEntity
from homeassistant.components.switch import (
PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA,
SwitchEntity,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_BROADCAST_ADDRESS, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME,
CONF_BROADCAST_PORT,
CONF_HOST,
CONF_MAC,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME, CONF_ENTITY_ID,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_platform,
) )
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.helpers import entity_platform, device_registry as dr
from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from paramiko.ssh_exception import AuthenticationException
from . import utils from .computer import OSType, Computer
from .const import SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, SERVICE_PUT_COMPUTER_TO_SLEEP, \ from .computer.utils import format_debug_information, get_bluetooth_devices_as_str
SERVICE_START_COMPUTER_TO_WINDOWS, SERVICE_RESTART_COMPUTER, SERVICE_RESTART_TO_LINUX_FROM_WINDOWS, \ from .const import (
SERVICE_CHANGE_MONITORS_CONFIG DOMAIN, SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, SERVICE_PUT_COMPUTER_TO_SLEEP,
SERVICE_START_COMPUTER_TO_WINDOWS, SERVICE_RESTART_COMPUTER,
_LOGGER = logging.getLogger(__name__) SERVICE_RESTART_TO_LINUX_FROM_WINDOWS, SERVICE_CHANGE_MONITORS_CONFIG,
SERVICE_STEAM_BIG_PICTURE, SERVICE_CHANGE_AUDIO_CONFIG, SERVICE_DEBUG_INFO
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,
}
)
SERVICE_CHANGE_MONITORS_CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Required("monitors_config"): dict,
}
) )
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigEntry, config: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
mac_address: str = config.data.get(CONF_MAC) """Set up the computer switch from a config entry."""
broadcast_address: str | None = config.data.get(CONF_BROADCAST_ADDRESS) mac_address = config.data[CONF_MAC]
broadcast_port: int | None = config.data.get(CONF_BROADCAST_PORT) host = config.data[CONF_HOST]
host: str = config.data.get(CONF_HOST) name = config.data[CONF_NAME]
name: str = config.data.get(CONF_NAME) dualboot = config.data.get("dualboot", False)
dualboot: bool = config.data.get("dualboot") username = config.data[CONF_USERNAME]
username: str = config.data.get(CONF_USERNAME) password = config.data[CONF_PASSWORD]
password: str = config.data.get(CONF_PASSWORD) port = config.data.get(CONF_PORT)
port: int | None = config.data.get(CONF_PORT)
async_add_entities( async_add_entities(
[ [ComputerSwitch(hass, name, host, mac_address, dualboot, username, password, port)],
ComputerSwitch( True
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 = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, # Service registrations
{}, services = [
SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, (SERVICE_RESTART_TO_WINDOWS_FROM_LINUX, {}, SupportsResponse.NONE),
) (SERVICE_RESTART_TO_LINUX_FROM_WINDOWS, {}, SupportsResponse.NONE),
platform.async_register_entity_service( (SERVICE_PUT_COMPUTER_TO_SLEEP, {}, SupportsResponse.NONE),
SERVICE_RESTART_TO_LINUX_FROM_WINDOWS, (SERVICE_START_COMPUTER_TO_WINDOWS, {}, SupportsResponse.NONE),
{}, (SERVICE_RESTART_COMPUTER, {}, SupportsResponse.NONE),
SERVICE_RESTART_TO_LINUX_FROM_WINDOWS, (SERVICE_CHANGE_MONITORS_CONFIG, {vol.Required("monitors_config"): dict}, SupportsResponse.NONE),
) (SERVICE_STEAM_BIG_PICTURE, {vol.Required("action"): str}, SupportsResponse.NONE),
platform.async_register_entity_service( (SERVICE_CHANGE_AUDIO_CONFIG, {
SERVICE_PUT_COMPUTER_TO_SLEEP, vol.Optional("volume"): int,
{}, vol.Optional("mute"): bool,
SERVICE_PUT_COMPUTER_TO_SLEEP, vol.Optional("input_device"): str,
) vol.Optional("output_device"): str
platform.async_register_entity_service( }, SupportsResponse.NONE),
SERVICE_START_COMPUTER_TO_WINDOWS, (SERVICE_DEBUG_INFO, {}, SupportsResponse.ONLY),
{}, ]
SERVICE_START_COMPUTER_TO_WINDOWS,
) # Register services with their schemas
platform.async_register_entity_service( for service_name, schema, supports_response in services:
SERVICE_RESTART_COMPUTER, platform.async_register_entity_service(
{}, service_name,
SERVICE_RESTART_COMPUTER, make_entity_service_schema(schema),
) service_name,
platform.async_register_entity_service( supports_response=supports_response
SERVICE_CHANGE_MONITORS_CONFIG, )
SERVICE_CHANGE_MONITORS_CONFIG_SCHEMA,
SERVICE_CHANGE_MONITORS_CONFIG,
)
class ComputerSwitch(SwitchEntity): class ComputerSwitch(SwitchEntity):
"""Representation of a computer switch.""" """Representation of a computer switch entity."""
def __init__( def __init__(
self, self,
@ -141,179 +83,121 @@ class ComputerSwitch(SwitchEntity):
name: str, name: str,
host: str | None, host: str | None,
mac_address: str, mac_address: str,
broadcast_address: str | None, dualboot: bool,
broadcast_port: int | None,
dualboot: bool | False,
username: str, username: str,
password: str, password: str,
port: int | None, port: int | None,
) -> None: ) -> None:
"""Initialize the WOL switch.""" """Initialize the computer switch entity."""
self.hass = hass
self._hass = hass
self._attr_name = name 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_unique_id = dr.format_mac(mac_address)
self._state = False
self._attr_should_poll = not self._attr_assumed_state
self._attr_extra_state_attributes = {} self._attr_extra_state_attributes = {}
self._connection = utils.create_ssh_connection(self._host, self._username, self._password)
self.computer = Computer(host, mac_address, username, password, port, dualboot)
@property
def device_info(self) -> DeviceInfo:
"""Return device info for the registry."""
return DeviceInfo(
identifiers={(DOMAIN, self.computer.mac)},
name=self._attr_name,
manufacturer="Generic",
model="Computer",
sw_version=self.computer.operating_system_version,
connections={(dr.CONNECTION_NETWORK_MAC, self.computer.mac)},
)
@property
def icon(self) -> str:
return "mdi:monitor" if self._state else "mdi:monitor-off"
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if the computer switch is on.""" """Return true if the computer is on."""
return self._state return self._state
def turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the computer on using wake on lan.""" """Turn the computer on using Wake-on-LAN."""
service_kwargs: dict[str, Any] = {} await self.computer.start()
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.debug(
"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: 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 async_turn_off(self, **kwargs: Any) -> None:
"""Turn the computer off using appropriate shutdown command based on running OS and/or distro.""" """Turn the computer off via shutdown command."""
utils.shutdown_system(self._connection) 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()
def restart_to_windows_from_linux(self) -> None: async def async_update(self) -> None:
"""Restart the computer to Windows from a running Linux by setting grub-reboot and restarting.""" """Update the state by checking if the computer is on."""
is_on = await self.computer.is_on()
if self.is_on != is_on:
self._state = is_on
# self.async_write_ha_state()
if self._dualboot: # If the computer is on, update its attributes
utils.restart_to_windows_from_linux(self._connection) if is_on:
else: await self.computer.update(is_on)
_LOGGER.error(
"The computer with the IP address %s is not running a dualboot system or hasn't been configured "
"correctly in the UI.",
self._host)
def restart_to_linux_from_windows(self) -> None:
"""Restart the computer to Linux from a running Windows by setting grub-reboot and restarting."""
if self._dualboot:
# TODO: check for default grub entry and adapt accordingly
utils.restart_system(self._connection)
else:
_LOGGER.error(
"The computer with the IP address %s is not running a dualboot system or hasn't been configured "
"correctly in the UI.",
self._host)
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.service_restart_to_windows_from_linux())
else:
_LOGGER.error(
"The computer with the IP address %s is not running a dualboot system or hasn't been configured "
"correctly in the UI.",
self._host)
async def service_restart_to_windows_from_linux(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 restart_computer(self) -> None:
"""Restart the computer using appropriate restart command based on running OS and/or distro."""
# TODO: check for default grub entry and adapt accordingly
if self._dualboot and not utils.is_unix_system(connection=self._connection):
utils.restart_system(self._connection)
# Wait for the computer to boot using a dedicated thread to avoid blocking the main thread
self.restart_to_windows_from_linux()
else:
utils.restart_system(self._connection)
def change_monitors_config(self, **kwargs) -> None:
"""Change the monitors configuration using a YAML config file."""
if kwargs["monitors_config"] is not None:
utils.change_monitors_config(self._connection, kwargs["monitors_config"])
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 to %s using username %s", self._host, self._username)
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 AuthenticationException as error:
_LOGGER.error("Could not authenticate to %s using username %s : %s", self._host, self._username,
error)
self._state = False
return
except Exception as error:
_LOGGER.error("Could not connect to %s using username %s : %s", self._host, self._username, error)
# Check if the error is due to timeout
if "timed out" in str(error):
_LOGGER.warning(
"Computer at %s does not respond to the SSH request. Possibles causes : might be offline, "
"the firewall is blocking the SSH port or the SSH server is offline and/or misconfigured.",
self._host)
self._state = False
return
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
"operating_system": utils.get_operating_system(self._connection), "operating_system": self.computer.operating_system,
"operating_system_version": utils.get_operating_system_version( "operating_system_version": self.computer.operating_system_version,
self._connection "mac_address": self.computer.mac,
), "ip_address": self.computer.host,
"mac_address": self._mac_address, "connected_devices": get_bluetooth_devices_as_str(self.computer),
"ip_address": self._host,
} }
# Service methods for various functionalities
async def restart_to_windows_from_linux(self) -> None:
"""Restart the computer from Linux to Windows."""
await self.computer.restart(OSType.LINUX, OSType.WINDOWS)
async def restart_to_linux_from_windows(self) -> None:
"""Restart the computer from Windows to Linux."""
await self.computer.restart(OSType.WINDOWS, OSType.LINUX)
async def put_computer_to_sleep(self) -> None:
"""Put the computer to sleep."""
await self.computer.put_to_sleep()
async def start_computer_to_windows(self) -> None:
"""Start the computer to Windows after booting into Linux first."""
await self.computer.start()
async def wait_and_reboot() -> None:
"""Wait until the computer is on, then restart to Windows."""
while not await self.computer.is_on():
await asyncio.sleep(3)
await self.computer.restart(OSType.LINUX, OSType.WINDOWS)
self.hass.loop.create_task(wait_and_reboot())
async def restart_computer(self) -> None:
"""Restart the computer."""
await self.computer.restart()
async def change_monitors_config(self, monitors_config: Dict[str, Any]) -> None:
"""Change the monitor configuration."""
await self.computer.set_monitors_config(monitors_config)
async def steam_big_picture(self, action: str) -> None:
"""Control Steam Big Picture mode."""
await self.computer.steam_big_picture(action)
async def change_audio_config(
self, volume: int | None = None, mute: bool | None = None,
input_device: str | None = None, output_device: str | None = None
) -> None:
"""Change the audio configuration."""
await self.computer.set_audio_config(volume, mute, input_device, output_device)
async def debug_info(self) -> ServiceResponse:
"""Return debug information."""
return await format_debug_information(self.computer)

View File

@ -1,25 +1,25 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Device is already configured" "already_configured": "Device is already configured"
}, },
"error": { "error": {
"cannot_connect": "Failed to connect", "cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication", "invalid_auth": "Invalid authentication",
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"step": { "step": {
"user": { "user": {
"data": { "data": {
"host": "Host", "host": "Host",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"dualboot": "Is this a Linux/Windows dualboot computer?", "dualboot": "Is this a Linux/Windows dualboot computer?",
"port": "Port", "port": "Port",
"name": "Name", "name": "Name",
"mac": "MAC Address" "mac": "MAC Address"
}
}
} }
}
} }
}
} }

View File

@ -1,25 +1,25 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "L'appareil est déjà configuré." "already_configured": "L'appareil est déjà configuré."
}, },
"error": { "error": {
"cannot_connect": "Impossible de se connecter à l'appareil.", "cannot_connect": "Impossible de se connecter à l'appareil.",
"invalid_auth": "Identifiant ou mot de passe invalide.", "invalid_auth": "Identifiant ou mot de passe invalide.",
"unknown": "Erreur inconnue." "unknown": "Erreur inconnue."
}, },
"step": { "step": {
"user": { "user": {
"data": { "data": {
"host": "Adresse IP", "host": "Adresse IP",
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
"password": "Mot de passe", "password": "Mot de passe",
"dualboot": "Est-ce que cet ordinateur est un dualboot Linux/Windows?", "dualboot": "Est-ce que cet ordinateur est un dualboot Linux/Windows?",
"port": "Port", "port": "Port",
"name": "Nom de l'appareil", "name": "Nom de l'appareil",
"mac": "Adresse MAC" "mac": "Adresse MAC"
}
}
} }
}
} }
}
} }

View File

@ -1,273 +0,0 @@
import logging
import re
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("Successfully created SSH connection to %s using username %s", host, username)
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:
result = connection.run(
"awk -F'=' '/^NAME=|^VERSION=/{gsub(/\"/, \"\", $2); printf $2\" \"}\' /etc/os-release && echo").stdout
if result == "":
result = connection.run(
"lsb_release -a | awk '/Description/ {print $2, $3, $4}'"
).stdout
return result
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 shutdown system running at %s, all methods failed.", connection.host)
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 shutdown system running at %s, all methods failed.", connection.host)
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 running at %s, all methods failed.", connection.host)
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 running at %s, all methods failed.", connection.host)
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 put system running at %s to sleep, all methods failed.", connection.host)
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 put system running at %s to sleep, all methods failed.", connection.host)
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("Successfully found Windows Grub entry (%s) for system running at %s.", result.stdout.strip(),
connection.host)
else:
_LOGGER.error("Could not find Windows entry on computer with address %s.")
return None
# Check if the entry is valid
if result.stdout.strip() != "":
return result.stdout.strip()
else:
_LOGGER.error("Could not find Windows entry on computer with address %s.")
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("Could not restart system running on %s to Windows from Linux, all methods failed.",
connection.host)
else:
_LOGGER.error(
"Could not restart system running on %s to Windows from Linux, system does not appear to be a Linux-based OS.",
connection.host)
def change_monitors_config(connection: Connection, monitors_config: dict):
"""From a YAML config, changes the monitors configuration on the host, only works on Linux and Gnome (for now)."""
# TODO: Add support for Windows
if is_unix_system(connection):
command_parts = ["gnome-monitor-config", "set"]
for monitor, settings in monitors_config.items():
if settings.get('enabled', False):
if 'primary' in settings and settings['primary']:
command_parts.append(f'-LpM {monitor}')
else:
command_parts.append(f'-LM {monitor}')
if 'position' in settings:
command_parts.append(f'-x {settings["position"][0]} -y {settings["position"][1]}')
if 'mode' in settings:
command_parts.append(f'-m {settings["mode"]}')
if 'scale' in settings:
command_parts.append(f'-s {settings["scale"]}')
if 'transform' in settings:
command_parts.append(f'-t {settings["transform"]}')
command = ' '.join(command_parts)
_LOGGER.debug("Running command: %s", command)
result = connection.run(command)
if result.return_code == 0:
_LOGGER.info("Successfully changed monitors config on system running on %s.", connection.host)
else:
_LOGGER.error("Could not change monitors config on system running on %s", connection.host)
# TODO : add useful debug info
else:
_LOGGER.error("Not implemented yet.")
def parse_gnome_monitor_config(output):
# SHOULD NOT BE USED YET, STILL IN DEVELOPMENT
"""Parse the output of the gnome-monitor-config command to get the current monitor configuration."""
monitors = []
current_monitor = None
for line in output.split('\n'):
monitor_match = re.match(r'^Monitor \[ (.+?) \] (ON|OFF)$', line)
if monitor_match:
if current_monitor:
monitors.append(current_monitor)
source, status = monitor_match.groups()
current_monitor = {'source': source, 'status': status, 'names': [], 'resolutions': []}
elif current_monitor:
display_name_match = re.match(r'^\s+display-name: (.+)$', line)
resolution_match = re.match(r'^\s+(\d+x\d+@\d+(?:\.\d+)?).*$', line)
if display_name_match:
current_monitor['names'].append(display_name_match.group(1).replace('"', ''))
elif resolution_match:
current_monitor['resolutions'].append(resolution_match.group(1))
if current_monitor:
monitors.append(current_monitor)
return monitors

View File

@ -1,5 +1,7 @@
{ {
"name": "Easy Computer Manager", "name": "Easy Computer Manager",
"country": ["CH"], "country": [
"CH"
],
"render_readme": true "render_readme": true
} }

View File

@ -1,5 +1,4 @@
fabric2~=3.1.0 wakeonlan~=3.1.0
paramiko~=3.2.0 homeassistant
voluptuous~=0.13.1 paramiko~=3.5.0
wakeonlan~=3.0.0 voluptuous~=0.15.2
homeassistant~=2023.7.2