├── .github └── workflows │ └── hacs.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── custom_components └── ssh_command │ ├── __init__.py │ ├── config_flow.py │ ├── manifest.json │ ├── services.yaml │ └── translations │ └── en.json ├── hacs.json └── tests └── test_backward.py /.github/workflows/hacs.yml: -------------------------------------------------------------------------------- 1 | name: HACS validation 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | hacs: 9 | runs-on: "ubuntu-latest" 10 | steps: 11 | - uses: "actions/checkout@v2" 12 | - uses: "hacs/action@main" 13 | with: { category: "integration", ignore: "brands" } 14 | hassfest: 15 | runs-on: "ubuntu-latest" 16 | steps: 17 | - uses: "actions/checkout@v3" 18 | - uses: "home-assistant/actions/hassfest@master" 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | __pycache__/ 3 | 4 | .homeassistant/ 5 | 6 | .idea/ 7 | 8 | tests/test_0.py 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 AlexxIT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSHCommand for Home Assistant 2 | 3 | Run any SSH command on remote server from [Home Assistant](https://www.home-assistant.io/) service call. For example, the command on the main host from the docker container. 4 | 5 | ## Installation 6 | 7 | [HACS](https://hacs.xyz/) custom repository: `AlexxIT/SSHCommand`. 8 | 9 | [![](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=AlexxIT&repository=SSHCommand&category=Integration) 10 | 11 | Or manually copy `ssh_command` folder from [latest release](https://github.com/AlexxIT/SSHCommand/releases/latest) to `/config/custom_components` folder. 12 | 13 | ## Configuration 14 | 15 | Add integration via Home Assistant UI or `configuration.yaml`. 16 | 17 | [![](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=ssh_command) 18 | 19 | ## Usage 20 | 21 | New service `ssh_command.exec_command`: 22 | 23 | ```yaml 24 | script: 25 | run_on_host: 26 | alias: Run shell command on host 27 | sequence: 28 | - service: ssh_command.exec_command 29 | data: 30 | host: 192.168.1.123 31 | port: 22 32 | user: pi 33 | pass: raspberry 34 | command: ls -la 35 | ``` 36 | 37 | Advanced usage: 38 | 39 | ```yaml 40 | script: 41 | run_on_host: 42 | alias: Run shell command on host 43 | sequence: 44 | - service: ssh_command.exec_command 45 | data: 46 | host: 192.168.1.123 # required hostname 47 | user: pi # required username 48 | pass: secret # optional password 49 | private_key: /config/ssh/id_rsa # optional private key filename 50 | passphrase: secret # optional private key passphrase 51 | timeout: 5 # optional timeout 52 | command: # also support multiple commands 53 | - touch somefile.tmp 54 | - ls -la 55 | ``` 56 | 57 | If you want use secrets or change default values, add them to `configuration.yaml`: 58 | 59 | ```yaml 60 | ssh_command: 61 | host: 192.168.1.123 62 | port: 22 63 | user: pi 64 | pass: !secret ssh_parssword 65 | ``` 66 | -------------------------------------------------------------------------------- /custom_components/ssh_command/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import voluptuous as vol 4 | from homeassistant.core import HomeAssistant, ServiceCall 5 | from homeassistant.helpers import config_validation as cv 6 | from paramiko import AutoAddPolicy, SSHClient 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | DOMAIN = "ssh_command" 11 | 12 | DEFAULT_SCHEMA = vol.Schema( 13 | { 14 | vol.Optional("host", default="172.17.0.1"): cv.string, 15 | vol.Optional("port", default=22): cv.port, 16 | vol.Optional("user", default="root"): cv.string, 17 | vol.Optional("pass"): cv.string, 18 | vol.Optional("private_key"): vol.Any(cv.string, cv.ensure_list), 19 | vol.Optional("passphrase"): cv.string, 20 | vol.Optional("timeout"): cv.positive_int, 21 | }, 22 | extra=vol.PREVENT_EXTRA, 23 | ) 24 | 25 | CONFIG_SCHEMA = vol.Schema({DOMAIN: DEFAULT_SCHEMA}, extra=vol.ALLOW_EXTRA) 26 | 27 | 28 | async def async_setup(hass: HomeAssistant, config: dict) -> bool: 29 | default = config.get(DOMAIN) or DEFAULT_SCHEMA({}) 30 | 31 | def exec_command(call: ServiceCall) -> dict: 32 | kwargs = default | call.data 33 | kwargs["hostname"] = kwargs.pop("host") 34 | kwargs["username"] = kwargs.pop("user") 35 | kwargs["password"] = kwargs.pop("pass", None) 36 | kwargs["key_filename"] = kwargs.pop("private_key", None) 37 | 38 | commands = kwargs.pop("command") 39 | if isinstance(commands, str): 40 | commands = [commands] 41 | 42 | client = SSHClient() 43 | client.set_missing_host_key_policy(AutoAddPolicy()) 44 | 45 | try: 46 | client.connect(**kwargs) 47 | 48 | for command in commands: 49 | _, stdout, stderr = client.exec_command( 50 | command, timeout=kwargs.get("timeout") 51 | ) 52 | 53 | # noinspection PyUnboundLocalVariable 54 | return { 55 | "stdout": stdout.read().decode("utf-8"), 56 | "stderr": stderr.read().decode("utf-8"), 57 | } 58 | 59 | except TimeoutError as e: 60 | _LOGGER.error(f"Command execution timeout") 61 | return {"error": repr(e)} 62 | 63 | except Exception as e: 64 | _LOGGER.error(f"Failed to connect: {repr(e)}") 65 | return {"error": repr(e)} 66 | 67 | finally: 68 | client.close() 69 | 70 | try: 71 | # ServiceResponse from Hass 2023.7 72 | # https://github.com/home-assistant/core/blob/2023.7.0/homeassistant/core.py 73 | from homeassistant.core import SupportsResponse 74 | 75 | hass.services.async_register( 76 | DOMAIN, 77 | "exec_command", 78 | exec_command, 79 | supports_response=SupportsResponse.OPTIONAL, 80 | ) 81 | except ImportError: 82 | hass.services.async_register(DOMAIN, "exec_command", exec_command) 83 | 84 | return True 85 | 86 | 87 | async def async_setup_entry(hass, entry): 88 | return True 89 | -------------------------------------------------------------------------------- /custom_components/ssh_command/config_flow.py: -------------------------------------------------------------------------------- 1 | from homeassistant.config_entries import ConfigFlow 2 | 3 | from . import DOMAIN 4 | 5 | 6 | class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): 7 | async def async_step_import(self, user_input=None): 8 | return await self.async_step_user() 9 | 10 | async def async_step_user(self, user_input=None): 11 | if self._async_current_entries(): 12 | return self.async_abort(reason="single_instance_allowed") 13 | return self.async_create_entry(title="SSH Command", data={}) 14 | -------------------------------------------------------------------------------- /custom_components/ssh_command/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "ssh_command", 3 | "name": "SSH Command", 4 | "codeowners": [ 5 | "@AlexxIT" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/AlexxIT/SSHCommand", 10 | "iot_class": "calculated", 11 | "issue_tracker": "https://github.com/AlexxIT/DashCast/issues", 12 | "requirements": [ 13 | "paramiko" 14 | ], 15 | "version": "1.2.0" 16 | } -------------------------------------------------------------------------------- /custom_components/ssh_command/services.yaml: -------------------------------------------------------------------------------- 1 | exec_command: 2 | fields: 3 | command: 4 | example: ls -la 5 | required: true 6 | selector: 7 | text: 8 | host: 9 | example: 172.17.0.1 10 | selector: 11 | text: 12 | port: 13 | example: 22 14 | selector: 15 | number: 16 | min: 1 17 | max: 65535 18 | user: 19 | example: root 20 | selector: 21 | text: 22 | pass: 23 | example: secret 24 | selector: 25 | text: 26 | private_key: 27 | example: /config/ssh/id_rsa 28 | selector: 29 | text: 30 | passphrase: 31 | example: secret 32 | selector: 33 | text: 34 | timeout: 35 | example: 5 36 | selector: 37 | number: 38 | min: 1 39 | max: 60 40 | -------------------------------------------------------------------------------- /custom_components/ssh_command/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Already configured. Only a single configuration possible." 5 | }, 6 | "step": {} 7 | }, 8 | "services": { 9 | "exec_command": { 10 | "name": "Execute command", 11 | "description": "Execute SSH command", 12 | "fields": { 13 | "command": { 14 | "name": "Command", 15 | "description": "" 16 | }, 17 | "host": { 18 | "name": "Host", 19 | "description": "" 20 | }, 21 | "port": { 22 | "name": "Port", 23 | "description": "" 24 | }, 25 | "user": { 26 | "name": "Username", 27 | "description": "" 28 | }, 29 | "pass": { 30 | "name": "Password", 31 | "description": "" 32 | }, 33 | "private_key": { 34 | "name": "Key filename", 35 | "description": "" 36 | }, 37 | "passphrase": { 38 | "name": "Key passphrase", 39 | "description": "" 40 | }, 41 | "timeout": { 42 | "name": "Connection timeout", 43 | "description": "" 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SSH Command", 3 | "render_readme": true 4 | } -------------------------------------------------------------------------------- /tests/test_backward.py: -------------------------------------------------------------------------------- 1 | from homeassistant.const import REQUIRED_PYTHON_VER 2 | 3 | from custom_components.ssh_command import * 4 | from custom_components.ssh_command.config_flow import * 5 | 6 | 7 | def test_backward(): 8 | # https://github.com/home-assistant/core/blob/2023.2.0/homeassistant/const.py 9 | assert REQUIRED_PYTHON_VER >= (3, 10, 0) 10 | 11 | assert async_setup_entry 12 | assert ConfigFlowHandler 13 | --------------------------------------------------------------------------------