├── tests ├── __init__.py ├── conftest.py ├── test_options_flow.py └── test_config_flow.py ├── custom_components └── pikvm_ha │ ├── sensors │ ├── __init__.py │ ├── pikvm_msd_enabled_sensor.py │ ├── pikvm_msd_drive_sensor.py │ ├── pikvm_extra_sensor.py │ ├── pikvm_throttling_sensor.py │ ├── pikvm_cpu_utilization_sensor.py │ ├── pikvm_cpu_temp_sensor.py │ ├── pikvm_memory_utilization_sensor.py │ ├── pikvm_fan_speed_sensor.py │ └── pikvm_msd_storage_sensor.py │ ├── const.py │ ├── manifest.json │ ├── translations │ └── en.json │ ├── entity.py │ ├── diagnostics.py │ ├── sensor.py │ ├── coordinator.py │ ├── __init__.py │ ├── utils.py │ ├── options_flow.py │ ├── cert_handler.py │ └── config_flow.py ├── Info.md ├── .gitignore ├── .vscode ├── extensions.json ├── settings.json ├── pikvm-ha.code-workspace ├── launch.json ├── scripts │ └── link-repository.sh └── tasks.json ├── hacs.json ├── pytest.ini ├── requirements_test.txt ├── .github └── workflows │ ├── hassfest.yaml │ └── validate.yaml ├── Dockerfile ├── docker-compose.yml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Info.md: -------------------------------------------------------------------------------- 1 | The PiKVM custom integration for Home Assistant. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | .coverage 4 | 5 | # Home Assistant config directory (created by docker-compose) 6 | ha_config/ -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.python", 4 | "keesschollaart.vscode-home-assistant" 5 | ] 6 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PiKVM", 3 | "content_in_root": false, 4 | "country": "US", 5 | "homeassistant": "2021.1.5", 6 | "render_readme": true 7 | } 8 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -ra 3 | asyncio_mode = auto 4 | asyncio_default_fixture_loop_scope = function 5 | testpaths = tests 6 | filterwarnings = 7 | ignore::DeprecationWarning 8 | ignore::PendingDeprecationWarning 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python-envs.defaultEnvManager": "ms-python.python:venv", 3 | "python-envs.pythonProjects": [], 4 | "python.testing.pytestArgs": [ 5 | "tests" 6 | ], 7 | "python.testing.unittestEnabled": false, 8 | "python.testing.pytestEnabled": true 9 | } -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | # Test dependencies for the PiKVM Home Assistant integration 2 | homeassistant>=2024.10.0,<2025.0.0 3 | josepy>=1.13.0,<2.0.0 4 | zeroconf==0.136.0 5 | pytest>=8.0.0,<9.0.0 6 | pytest-asyncio>=0.24.0,<0.25.0 7 | pytest-cov>=5.0.0,<6.0.0 8 | pytest-homeassistant-custom-component>=0.13.0,<0.14.0 9 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /.vscode/pikvm-ha.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": "PiKVM Integration", 5 | "path": ".." 6 | }, 7 | { 8 | "name": "Home Assistant Config Folder", 9 | "path": "/config" 10 | } 11 | ], 12 | "settings": { 13 | "files.associations": { 14 | "*.yaml": "home-assistant" 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM homeassistant/home-assistant:latest 2 | 3 | # Copy the custom component into the container 4 | # This will be overridden by the volume mount in docker-compose 5 | COPY custom_components/pikvm_ha /config/custom_components/pikvm_ha 6 | 7 | # Expose the default Home Assistant port 8 | EXPOSE 8123 9 | 10 | # The base image already has the correct entrypoint 11 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the PiKVM integration.""" 2 | 3 | DOMAIN = "pikvm_ha" 4 | CONF_MODEL = "model" 5 | CONF_HOST = "url" 6 | CONF_USERNAME = "username" 7 | CONF_PASSWORD = "password" 8 | CONF_CERTIFICATE = "tls-certificate" 9 | CONF_SERIAL = "serial" 10 | DEFAULT_USERNAME = "admin" 11 | DEFAULT_PASSWORD = "admin" 12 | MANUFACTURER = "PiKVM" 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python Debugger: Attach using Process Id", 9 | "type": "debugpy", 10 | "request": "attach", 11 | "processId": "${command:pickProcess}" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /custom_components/pikvm_ha/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "pikvm_ha", 3 | "name": "PiKVM", 4 | "codeowners": ["@adamoutler"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/adamoutler/pikvm-homeassistant-integration", 8 | "iot_class": "local_polling", 9 | "issue_tracker": "https://github.com/adamoutler/pikvm-homeassistant-integration/issues", 10 | "requirements": [ 11 | "pyOpenSSL>=24.2.1", 12 | "requests>=2.32.3", 13 | "voluptuous>=0.15.2" 14 | ], 15 | "version": "1.0.0", 16 | "zeroconf": [ 17 | "_pikvm._tcp.local." 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | homeassistant: 5 | container_name: homeassistant 6 | image: homeassistant/home-assistant:latest 7 | volumes: 8 | # Mount the custom component directly into Home Assistant's custom_components directory 9 | - ./custom_components/pikvm_ha:/config/custom_components/pikvm_ha:ro 10 | # Mount a local config directory for Home Assistant data persistence 11 | - ./ha_config:/config 12 | ports: 13 | - "8123:8123" 14 | restart: unless-stopped 15 | privileged: true 16 | network_mode: host 17 | environment: 18 | - TZ=UTC 19 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | 16 | # Add a cache step to invalidate the cache 17 | - name: Cache validation data 18 | uses: actions/cache@v3 19 | with: 20 | path: ~/.cache 21 | key: ${{ runner.os }}-validation-${{ github.run_number }} 22 | restore-keys: | 23 | ${{ runner.os }}-validation- 24 | 25 | - name: HACS validation 26 | uses: "hacs/action@main" 27 | with: 28 | category: "Integration" 29 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "url": "URL or IP address of the PiKVM device", 7 | "username": "Username for PiKVM", 8 | "password": "Password for PiKVM" 9 | } 10 | } 11 | }, 12 | "abort": { 13 | "already_configured": "The device is already configured in Home Assistant, and the information is now updated." 14 | }, 15 | "error": { 16 | "cannot_fetch_cert": "Cannot fetch certificate", 17 | "cannot_connect": "Cannot connect to PiKVM device", 18 | "Exception_HTTP403": "Invalid username or password", 19 | "Exception_HTTP502": "Bad Gateway. PiKVM isn't ready yet." 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/pikvm_msd_enabled_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for PiKVM MSD enabled sensor.""" 2 | 3 | from homeassistant.const import EntityCategory 4 | 5 | from ..sensor import PiKVMBaseSensor 6 | 7 | 8 | class PiKVMSDEnabledSensor(PiKVMBaseSensor): 9 | """Representation of a PiKVM MSD enabled sensor.""" 10 | 11 | def __init__(self, coordinator, unique_id_base, device_name) -> None: 12 | """Initialize the sensor.""" 13 | name = f"{device_name} MSD Enabled" 14 | super().__init__( 15 | coordinator, 16 | unique_id_base, 17 | "msd_enabled", 18 | name, 19 | icon="mdi:check-circle", 20 | ) 21 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 22 | 23 | @property 24 | def state(self) -> bool: 25 | """Return the state of the sensor.""" 26 | return self.coordinator.data["msd"]["enabled"] 27 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/entity.py: -------------------------------------------------------------------------------- 1 | """PiKVM entity base class.""" 2 | 3 | import logging 4 | 5 | from homeassistant.helpers.device_registry import DeviceInfo 6 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 7 | 8 | from .coordinator import PiKVMDataUpdateCoordinator 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class PiKVMEntity(CoordinatorEntity): 14 | """Base class for a PiKVM entity.""" 15 | 16 | DEVICE_INFO: DeviceInfo | None = None 17 | coordinator: PiKVMDataUpdateCoordinator 18 | 19 | def __init__( 20 | self, coordinator: PiKVMDataUpdateCoordinator, unique_id_base: str 21 | ) -> None: 22 | """Initialize the entity.""" 23 | super().__init__(coordinator) 24 | self.coordinator = coordinator 25 | self._attr_device_info = self.DEVICE_INFO 26 | self._attr_unique_id_base = unique_id_base 27 | self._attr_device_info = self.DEVICE_INFO 28 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/pikvm_msd_drive_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for PiKVM MSD drive sensor.""" 2 | 3 | from ..sensor import PiKVMBaseSensor 4 | 5 | 6 | class PiKVMSDDriveSensor(PiKVMBaseSensor): 7 | """Representation of a PiKVM MSD drive sensor.""" 8 | 9 | def __init__(self, coordinator, unique_id_base, device_name) -> None: 10 | """Initialize the sensor.""" 11 | name = f"{device_name} MSD Drive" 12 | super().__init__(coordinator, unique_id_base, "msd_drive", name, icon="mdi:usb") 13 | 14 | @property 15 | def state(self): 16 | """Return the state of the sensor.""" 17 | return self.coordinator.data["msd"]["drive"]["connected"] 18 | 19 | @property 20 | def extra_state_attributes(self): 21 | """Return the state attributes.""" 22 | attributes = super().extra_state_attributes 23 | drive_data = self.coordinator.data["msd"]["drive"] 24 | if drive_data: 25 | attributes.update(drive_data) 26 | return attributes 27 | -------------------------------------------------------------------------------- /.vscode/scripts/link-repository.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | # Function to reload Home Assistant 5 | reload_home_assistant() { 6 | echo "Reloading Home Assistant..." 7 | # Assuming you have the Home Assistant CLI (hass-cli) installed 8 | ha core restart 9 | } 10 | 11 | # Check if /config/custom_components directory exists 12 | if [ ! -d /config/custom_components ]; then 13 | echo "Cannot find custom components directory" 14 | exit 1 15 | fi 16 | 17 | # Unlink /config/custom_components/pikvm if it's a symbolic link 18 | if [ -L /config/custom_components/pikvm_ha ]; then 19 | unlink /config/custom_components/pikvm_ha 20 | fi 21 | 22 | # Check if /config/custom_components/pikvm folder already exists 23 | if [ -d /config/custom_components/pikvm_ha ]; then 24 | echo "/config/custom_components/pikvm_ha folder already exists" 25 | exit 1 26 | fi 27 | 28 | # Check if custom_components directory exists in the current workspace 29 | if [ ! -d custom_components ]; then 30 | echo "This must be run from the root of the pikvm workspace" 31 | exit 1 32 | fi 33 | 34 | # Create symbolic link 35 | ln -s "$(pwd)/custom_components/pikvm_ha" /config/custom_components/pikvm_ha 36 | echo "Linking Successful" 37 | 38 | # Reload Home Assistant 39 | #reload_home_assistant 40 | echo "Press F1->Tasks:-> Restart Home Assistant if changes have not taken effect." 41 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/pikvm_extra_sensor.py: -------------------------------------------------------------------------------- 1 | """Module for PiKVM extra sensor integration.""" 2 | 3 | from homeassistant.const import EntityCategory 4 | 5 | from ..sensor import PiKVMBaseSensor 6 | 7 | 8 | class PiKVMExtraSensor(PiKVMBaseSensor): 9 | """Representation of a PiKVM extra sensor.""" 10 | 11 | ICONS = { 12 | "ipmi": "mdi:network", 13 | "janus": "mdi:web", 14 | "janus_static": "mdi:web", 15 | "vnc": "mdi:monitor", 16 | "webterm": "mdi:console", 17 | } 18 | 19 | def __init__(self, coordinator, name, data, unique_id_base, device_name) -> None: 20 | """Initialize the sensor.""" 21 | icon = self.ICONS.get(name, "mdi:information") 22 | sensor_name = f"{device_name} {name.capitalize()}" 23 | super().__init__( 24 | coordinator, 25 | unique_id_base, 26 | f"extra_{name}", 27 | sensor_name, 28 | icon=icon, 29 | ) 30 | self._data = data 31 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 32 | 33 | @property 34 | def state(self): 35 | """Return the state of the sensor.""" 36 | return self._data["enabled"] 37 | 38 | @property 39 | def extra_state_attributes(self): 40 | """Return the state attributes.""" 41 | attributes = super().extra_state_attributes 42 | attributes.update(self._data) 43 | return attributes 44 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/pikvm_throttling_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for PiKVM throttling sensor.""" 2 | 3 | from ..sensor import PiKVMBaseSensor 4 | 5 | 6 | class PiKVMThrottlingSensor(PiKVMBaseSensor): 7 | """Representation of a PiKVM throttling sensor.""" 8 | 9 | def __init__(self, coordinator, unique_id_base, device_name) -> None: 10 | """Initialize the sensor.""" 11 | name = f"{device_name} Throttling" 12 | super().__init__( 13 | coordinator, 14 | unique_id_base, 15 | "throttling", 16 | name, 17 | icon="mdi:alert", 18 | ) 19 | 20 | @property 21 | def state(self): 22 | """Return the state of the sensor.""" 23 | return ( 24 | self.coordinator.data["hw"]["health"] 25 | .get("throttling", {}) 26 | .get("raw_flags", 0) 27 | ) 28 | 29 | @property 30 | def extra_state_attributes(self) -> dict: 31 | """Return the state attributes.""" 32 | throttling_data = self.coordinator.data["hw"]["health"].get("throttling", {}) 33 | flattened_data = {} 34 | for key, value in throttling_data.items(): 35 | if isinstance(value, dict): 36 | for sub_key, sub_value in value.items(): 37 | if isinstance(sub_value, dict): 38 | for sub_sub_key, sub_sub_value in sub_value.items(): 39 | flattened_data[f"{sub_key}.{sub_sub_key}"] = sub_sub_value 40 | else: 41 | flattened_data[f"{key}.{sub_key}"] = sub_value 42 | else: 43 | flattened_data[key] = value 44 | return flattened_data 45 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Restart HA", 8 | "type": "shell", 9 | "command": "docker restart homeassistant", 10 | "problemMatcher": [], 11 | "icon": {"id": "debug-restart"} 12 | }, 13 | { 14 | "label": "logs", 15 | "type": "shell", 16 | "command": "docker logs homeassistant|tail -f", 17 | "problemMatcher": [], 18 | "icon": {"id": "search"} 19 | }, 20 | { 21 | "label": "Link Repository", 22 | "type": "shell", 23 | "command": "${workspaceFolder}/.vscode/scripts/link-repository.sh", 24 | "problemMatcher": [], 25 | "presentation": { 26 | "reveal": "always", 27 | "panel": "new" 28 | }, 29 | "runOptions": { 30 | "runOn": "folderOpen" 31 | }, 32 | "icon": {"id": "link"} 33 | }, 34 | { 35 | "label": "Install Home Assistant for source references", 36 | "type": "shell", 37 | "command":" pip3 list|grep homeassistant||pip3 install homeassistant pyopenssl voluptuous requests urllib3 --no-deps --break-system-packages", 38 | "presentation": { 39 | "reveal": "always", 40 | "panel": "new" 41 | }, 42 | "runOptions": { 43 | "runOn": "folderOpen" 44 | } 45 | , 46 | "icon": {"id": "add"} 47 | } 48 | ] 49 | } -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/pikvm_cpu_utilization_sensor.py: -------------------------------------------------------------------------------- 1 | """Module for the PiKVMCpuUtilizationSensor class. 2 | 3 | Represents a sensor for monitoring the CPU utilization of a PiKVM device. 4 | """ 5 | 6 | import logging 7 | 8 | from .. import PiKVMDataUpdateCoordinator 9 | from ..sensor import PiKVMBaseSensor 10 | from ..utils import get_nested_value 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class PiKVMCpuUtilizationSensor(PiKVMBaseSensor): 16 | """Representation of a PiKVM CPU temperature sensor.""" 17 | 18 | def __init__( 19 | self, 20 | coordinator: PiKVMDataUpdateCoordinator, 21 | unique_id_base, 22 | device_name, 23 | ) -> None: 24 | """Initialize the sensor.""" 25 | name = f"{device_name} CPU Utilization" 26 | super().__init__( 27 | coordinator, 28 | unique_id_base, 29 | "cpu_utilization", 30 | name, 31 | "%", 32 | "mdi:cpu-64-bit", 33 | ) 34 | 35 | @property 36 | def state(self): 37 | """Return the state of the sensor in preferred units.""" 38 | return get_nested_value( 39 | self.coordinator.data, ["hw", "health", "cpu", "percent"] 40 | ) 41 | 42 | @property 43 | def available(self): 44 | """Return True if the sensor data is available.""" 45 | return "cpu" in get_nested_value(self.coordinator.data, ["hw", "health"]) 46 | 47 | @property 48 | def unit_of_measurement(self): 49 | """Return the preferred units of measure.""" 50 | return "%" 51 | 52 | @property 53 | def extra_state_attributes(self): 54 | """Return the state attributes.""" 55 | return super().extra_state_attributes 56 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global pytest fixtures for PiKVM integration tests.""" 2 | 3 | from unittest.mock import AsyncMock, patch 4 | 5 | import pytest 6 | 7 | pytest_plugins = "pytest_homeassistant_custom_component" 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def auto_enable_custom_integrations(enable_custom_integrations): 12 | """Enable custom integrations like this one for every test.""" 13 | yield 14 | 15 | 16 | @pytest.fixture(autouse=True, scope="session") 17 | def mock_setup_entry_calls(): 18 | """Avoid setting up the actual integration during config flow tests.""" 19 | with ( 20 | patch( 21 | "custom_components.pikvm_ha.async_setup_entry", 22 | new=AsyncMock(return_value=True), 23 | ), 24 | patch( 25 | "custom_components.pikvm_ha.async_unload_entry", 26 | new=AsyncMock(return_value=True), 27 | ), 28 | ): 29 | yield 30 | 31 | 32 | @pytest.fixture 33 | def pikvm_cert(): 34 | """Return a synthetic PEM certificate used by the unit tests.""" 35 | return ( 36 | "-----BEGIN CERTIFICATE-----\n" 37 | "MIIBtjCCAVugAwIBAgIJAO2b2k93r7cKMAoGCCqGSM49BAMCMBUxEzARBgNVBAMM\n" 38 | "CnBpa3ZtLmxvY2FsMB4XDTIwMTAxMDEwMDAwMFoXDTMwMDkyNzEwMDAwMFowFTET\n" 39 | "MBEGA1UEAwwKcGlrdm0ubG9jYWwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATL\n" 40 | "7fUG9zO7g0ZmXGf1DsKpP+NBo7GdA51N2bYzu3n6PvJEa3TBUnIFVQGryuVKyXjH\n" 41 | "fS9Sz3gwxMZ2ymlkAkQHo1MwUTAdBgNVHQ4EFgQUYVtz1xuxMxDPZWS9Vyuk3F7S\n" 42 | "LCQwHwYDVR0jBBgwFoAUYVtz1xuxMxDPZWS9Vyuk3F7SLCQwDwYDVR0TAQH/BAUw\n" 43 | "AwEB/zAKBggqhkjOPQQDAgNJADBGAiEAi0eZZ+j9RnBbTK1ZBOqVakiobP6KyHRx\n" 44 | "0JVpaz6RtNkCIQCNux41DmvNmO6PsK0uFUxnCLzpSw0eVUsVTNff7kwhWA==\n" 45 | "-----END CERTIFICATE-----" 46 | ) 47 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/pikvm_cpu_temp_sensor.py: -------------------------------------------------------------------------------- 1 | """The PiKVMCpuTempSensor class, which represents a CPU temperature sensor for PiKVM.""" 2 | 3 | import logging 4 | 5 | from homeassistant.const import UnitOfTemperature 6 | from homeassistant.helpers import temperature 7 | 8 | from .. import PiKVMDataUpdateCoordinator 9 | from ..sensor import PiKVMBaseSensor 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | # Function to convert temperature 15 | def convert_temperature(value, from_unit, to_unit): 16 | """Convert temperature from one unit to another.""" 17 | if from_unit == to_unit: 18 | return value 19 | return temperature.convert_temperature(value, from_unit, to_unit) 20 | 21 | 22 | class PiKVMCpuTempSensor(PiKVMBaseSensor): 23 | """Representation of a PiKVM CPU temperature sensor.""" 24 | 25 | def __init__( 26 | self, 27 | coordinator: PiKVMDataUpdateCoordinator, 28 | unique_id_base: str, 29 | device_name: str, 30 | ) -> None: 31 | """Initialize the sensor.""" 32 | name = f"{device_name} CPU Temp" 33 | super().__init__( 34 | coordinator, 35 | unique_id_base, 36 | "cpu_temp", 37 | name, 38 | "°C", 39 | "mdi:thermometer", 40 | ) 41 | 42 | @property 43 | def state(self): 44 | """Return the state of the sensor in preferred units.""" 45 | value = self.coordinator.data["hw"]["health"]["temp"]["cpu"] 46 | try: 47 | temp_value = float(value) 48 | except (TypeError, ValueError): 49 | return None # or handle the error appropriately 50 | return self.coordinator.hass.config.units.temperature( 51 | temp_value, UnitOfTemperature.CELSIUS 52 | ) 53 | 54 | @property 55 | def unit_of_measurement(self): 56 | """Return the preferred units of measure.""" 57 | return self.coordinator.hass.config.units.temperature_unit 58 | 59 | @property 60 | def extra_state_attributes(self): 61 | """Return the state attributes.""" 62 | return super().extra_state_attributes 63 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/pikvm_memory_utilization_sensor.py: -------------------------------------------------------------------------------- 1 | """Represents a sensor for monitoring memory utilization on a PiKVM device.""" 2 | 3 | import logging 4 | from typing import NamedTuple 5 | 6 | from .. import PiKVMDataUpdateCoordinator 7 | from ..sensor import PiKVMBaseSensor 8 | from ..utils import bytes_to_mb, get_nested_value 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class PiKVMResponse(NamedTuple): 14 | """Represents a PiKVM response.""" 15 | 16 | success: bool 17 | model: str 18 | serial: str 19 | name: str 20 | error: str 21 | 22 | 23 | class PiKVMMemoryUtilizationSensor(PiKVMBaseSensor): 24 | """Representation of a PiKVM CPU temperature sensor.""" 25 | 26 | def __init__( 27 | self, 28 | coordinator: PiKVMDataUpdateCoordinator, 29 | unique_id_base, 30 | device_name, 31 | ) -> None: 32 | """Initialize the sensor.""" 33 | name = f"{device_name} Memory Utilization" 34 | super().__init__( 35 | coordinator, 36 | unique_id_base, 37 | "memory_utilization", 38 | name, 39 | "%", 40 | "mdi:memory", 41 | ) 42 | 43 | @property 44 | def state(self): 45 | """Return the state of the sensor in preferred units.""" 46 | return get_nested_value( 47 | self.coordinator.data, ["hw", "health", "mem", "percent"] 48 | ) 49 | 50 | @property 51 | def available(self): 52 | """Return True if the sensor data is available.""" 53 | return "mem" in get_nested_value(self.coordinator.data, ["hw", "health"]) 54 | 55 | @property 56 | def unit_of_measurement(self): 57 | """Return the preferred units of measure.""" 58 | return "%" 59 | 60 | @property 61 | def extra_state_attributes(self): 62 | """Return the state attributes.""" 63 | attributes = super().extra_state_attributes 64 | available_bytes = get_nested_value( 65 | self.coordinator.data, ["hw", "health", "mem", "available"] 66 | ) 67 | total_bytes = get_nested_value( 68 | self.coordinator.data, ["hw", "health", "mem", "total"] 69 | ) 70 | attributes["available MB"] = bytes_to_mb(available_bytes) 71 | attributes["total MB"] = bytes_to_mb(total_bytes) 72 | return attributes 73 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/pikvm_fan_speed_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for PiKVM fan speed sensor.""" 2 | 3 | from ..sensor import PiKVMBaseSensor 4 | from ..utils import get_nested_value 5 | 6 | 7 | class PiKVMFanSpeedSensor(PiKVMBaseSensor): 8 | """Representation of a PiKVM fan speed sensor.""" 9 | 10 | def __init__(self, coordinator, unique_id_base, device_name) -> None: 11 | """Initialize the sensor.""" 12 | name = f"{device_name} Fan Speed" 13 | super().__init__(coordinator, unique_id_base, "fan_speed", name, icon="mdi:fan") 14 | 15 | # Ensure fan_data is not None 16 | self.hall_available = False 17 | if coordinator.data and coordinator.data.get("fan"): 18 | fan_data = coordinator.data.get("fan", {}).get("state", {}) 19 | if fan_data: 20 | hall_data = fan_data.get("hall", {}) 21 | if hall_data: 22 | self.hall_available = hall_data.get("available", False) 23 | 24 | # Set the unit of measurement based on hall availability 25 | self._attr_unit_of_measurement = "RPM" if self.hall_available else "%" 26 | 27 | @property 28 | def available(self): 29 | """Return True if the sensor data is available.""" 30 | return "state" in self.coordinator.data["fan"] 31 | 32 | @property 33 | def state(self): 34 | """Return the state of the sensor.""" 35 | if ( 36 | not self.coordinator.data 37 | or not self.coordinator.data.get("fan") 38 | or not self.coordinator.data.get("fan", {}).get("state") 39 | ): 40 | return None 41 | 42 | if self.hall_available: 43 | hall_data = get_nested_value( 44 | self.coordinator.data, ["fan", "state", "hall"] 45 | ) 46 | return hall_data.get("rpm", None) if hall_data else None 47 | 48 | fan_data = get_nested_value(self.coordinator.data, ["fan", "state", "fan"]) 49 | return fan_data.get("speed", None) if fan_data else None 50 | 51 | @property 52 | def extra_state_attributes(self): 53 | """Return the state attributes.""" 54 | attributes = super().extra_state_attributes 55 | if ( 56 | self.coordinator.data 57 | and self.coordinator.data.get("fan") 58 | and self.coordinator.data.get("fan", {}).get("state") 59 | ): 60 | fan_data = self.coordinator.data.get("fan", {}).get("state", {}) 61 | if fan_data: 62 | attributes.update(fan_data) 63 | return attributes 64 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensors/pikvm_msd_storage_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for PiKVM MSD storage sensor.""" 2 | 3 | import logging 4 | 5 | from ..sensor import PiKVMBaseSensor 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | class PiKVMSDStorageSensor(PiKVMBaseSensor): 11 | """Representation of a PiKVM MSD storage sensor.""" 12 | 13 | def __init__(self, coordinator, unique_id_base, device_name) -> None: 14 | """Initialize the sensor.""" 15 | name = f"{device_name} MSD Storage" 16 | super().__init__( 17 | coordinator, 18 | unique_id_base, 19 | "msd_storage", 20 | name, 21 | "%", 22 | "mdi:database", 23 | ) 24 | 25 | @property 26 | def state(self): 27 | coordinator_data = getattr(self.coordinator, "data", {}) or {} 28 | storage = coordinator_data.get("msd", {}).get("storage", {}) 29 | total = storage.get("size") 30 | free = storage.get("free") 31 | if total is None or free is None or total <= 0: 32 | _LOGGER.debug("MSD storage data missing or invalid: %r", storage) 33 | return None # marks sensor unavailable 34 | return round((free / total) * 100, 2) 35 | 36 | @property 37 | def extra_state_attributes(self): 38 | """Return the state attributes.""" 39 | attributes = super().extra_state_attributes 40 | coordinator_data = getattr(self.coordinator, "data", {}) or {} 41 | storage_data = coordinator_data.get("msd", {}).get("storage", {}) or {} 42 | images = storage_data.get("images", {}) or {} 43 | 44 | if storage_data: 45 | size = storage_data.get("size") 46 | free = storage_data.get("free") 47 | if size is not None: 48 | attributes["total_size_mb"] = round(size / (1024 * 1024), 2) 49 | if free is not None: 50 | attributes["free_size_mb"] = round(free / (1024 * 1024), 2) 51 | if size is not None and free is not None: 52 | attributes["used_size_mb"] = round((size - free) / (1024 * 1024), 2) 53 | state = self.state 54 | if state is not None: 55 | attributes["percent_free"] = state 56 | 57 | if images: 58 | if len(images) < 20: 59 | for image, details in images.items(): 60 | size = details.get("size") 61 | if size is not None: 62 | attributes[image] = size 63 | else: 64 | attributes["file count"] = len(images) 65 | return attributes 66 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics for PiKVM integration.""" 2 | 3 | from collections.abc import Mapping 4 | import json 5 | import logging 6 | from threading import Lock 7 | from types import MappingProxyType 8 | from typing import Any 9 | 10 | from homeassistant.config_entries import ConfigEntry, ConfigEntryState 11 | from homeassistant.core import HomeAssistant 12 | 13 | from .const import DOMAIN 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | async def async_get_config_entry_diagnostics( 19 | hass: HomeAssistant, config_entry: ConfigEntry 20 | ) -> Mapping[str, Any]: 21 | """Return diagnostics for a config entry.""" 22 | coordinator = hass.data[DOMAIN].get(config_entry.entry_id) 23 | 24 | diagnostics_data = { 25 | "config_entry": _mask_sensitive_data(_expand_mapping_proxy(vars(config_entry))), 26 | "coordinator": { 27 | "last_update_success": coordinator.last_update_success 28 | if coordinator 29 | else None, 30 | "update_interval": str(coordinator.update_interval) 31 | if coordinator 32 | else None, 33 | "states": _mask_sensitive_data(_expand_mapping_proxy(coordinator.data)) 34 | if coordinator 35 | else {}, 36 | } 37 | if coordinator 38 | else {}, 39 | } 40 | 41 | # Sanitize diagnostics data before serialization 42 | sanitized_data = _sanitize_data(diagnostics_data) 43 | 44 | # Pretty-print the diagnostics data 45 | try: 46 | pretty_diagnostics = json.dumps( 47 | {config_entry.entry_id: sanitized_data}, 48 | indent=4, 49 | default=_default_json_serialize, 50 | ) 51 | _LOGGER.debug("Diagnostics data: %s", pretty_diagnostics) 52 | except TypeError as e: 53 | _LOGGER.error("Failed to serialize diagnostics data: %s", e) 54 | 55 | return sanitized_data 56 | 57 | 58 | def _mask_sensitive_data(data): 59 | """Mask sensitive data such as passwords.""" 60 | if not data: 61 | return data 62 | 63 | def mask_item(item): 64 | """Masks sensitive data in a single item.""" 65 | if isinstance(item, dict): 66 | return {k: "******" if "password" in k else v for k, v in item.items()} 67 | if isinstance(item, list): 68 | return [mask_item(i) for i in item] 69 | if isinstance(item, MappingProxyType): 70 | return mask_item(dict(item)) 71 | return item 72 | 73 | return mask_item(data) 74 | 75 | 76 | def _expand_mapping_proxy(data): 77 | """Expand a mapping proxy to a dictionary.""" 78 | if isinstance(data, MappingProxyType): 79 | return dict(data) 80 | if isinstance(data, dict): 81 | return {k: _expand_mapping_proxy(v) for k, v in data.items()} 82 | if isinstance(data, list): 83 | return [_expand_mapping_proxy(item) for item in data] 84 | return data 85 | 86 | 87 | def _default_json_serialize(obj): 88 | """JSON serializer for objects not serializable by default json code.""" 89 | if isinstance(obj, MappingProxyType): 90 | return dict(obj) 91 | if isinstance(obj, ConfigEntryState): 92 | return obj.name 93 | if isinstance(obj, Lock): 94 | return "Lock" 95 | if callable(obj): 96 | return None # Skip functions 97 | raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable") 98 | 99 | 100 | def _sanitize_data(data): 101 | """Sanitize data by removing non-serializable types.""" 102 | if isinstance(data, dict): 103 | return {k: _sanitize_data(v) for k, v in data.items() if not callable(v)} 104 | if isinstance(data, list): 105 | return [_sanitize_data(i) for i in data if not callable(i)] 106 | return data 107 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | 3 | from collections.abc import Mapping 4 | import logging 5 | 6 | from voluptuous import Any 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | 12 | from .const import DOMAIN 13 | from .entity import PiKVMEntity 14 | from .utils import get_unique_id_base 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class PiKVMBaseSensor(PiKVMEntity): 20 | """Base class for a PiKVM sensor.""" 21 | 22 | def __init__( 23 | self, 24 | coordinator, 25 | unique_id_base, 26 | sensor_type, 27 | name, 28 | unit=None, 29 | icon=None, 30 | ) -> None: 31 | """Initialize the sensor.""" 32 | super().__init__(coordinator, unique_id_base) 33 | self._attr_unique_id = f"{unique_id_base}_{sensor_type}" 34 | self._attr_name = name 35 | self._attr_unit_of_measurement = unit 36 | self._attr_icon = icon 37 | self._unique_id_base = unique_id_base 38 | self._sensor_type = sensor_type 39 | 40 | @property 41 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 42 | """Return the state attributes.""" 43 | return {"ip": self.coordinator.url} 44 | 45 | @property 46 | def state(self) -> str | int | float | bool | None: 47 | """Return the state of the sensor.""" 48 | raise NotImplementedError( 49 | "The state method must be implemented by the subclass." 50 | ) 51 | 52 | 53 | async def async_setup_entry( 54 | hass: HomeAssistant, 55 | config_entry: ConfigEntry, 56 | async_add_entities: AddEntitiesCallback, 57 | ) -> None: 58 | """Set up PiKVM sensors from a config entry.""" 59 | _LOGGER.debug("Setting up PiKVM sensors from config entry") 60 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 61 | unique_id_base = get_unique_id_base(config_entry, coordinator) 62 | device_name = coordinator.data["meta"]["server"]["host"] 63 | 64 | # Use "pikvm" if the device name is "localhost.localdomain" 65 | if device_name == "localhost.localdomain": 66 | device_name = DOMAIN 67 | else: 68 | device_name = device_name.replace(".", "_") 69 | 70 | lazy_import_sensors() 71 | # List of sensors to create 72 | # List of sensors to create 73 | # Get sensor classes lazily 74 | sensor_classes = lazy_import_sensors() 75 | 76 | # List of sensors to create 77 | sensors = [ 78 | sensor_classes["cpu_utilization"](coordinator, unique_id_base, device_name), 79 | sensor_classes["memory_utilization"](coordinator, unique_id_base, device_name), 80 | sensor_classes["cpu_temp"](coordinator, unique_id_base, device_name), 81 | sensor_classes["fan_speed"](coordinator, unique_id_base, device_name), 82 | sensor_classes["throttling"](coordinator, unique_id_base, device_name), 83 | sensor_classes["msd_enabled"](coordinator, unique_id_base, device_name), 84 | sensor_classes["msd_drive"](coordinator, unique_id_base, device_name), 85 | sensor_classes["msd_storage"](coordinator, unique_id_base, device_name), 86 | ] 87 | 88 | # Dynamically create sensors for extras 89 | for extra_name, extra_data in coordinator.data["extras"].items(): 90 | sensors.append( 91 | sensor_classes["extra"]( 92 | coordinator, 93 | extra_name, 94 | extra_data, 95 | unique_id_base, 96 | device_name, 97 | ) 98 | ) 99 | 100 | async_add_entities(sensors, True) 101 | _LOGGER.debug("%s PiKVM sensors added to Home Assistant", device_name) 102 | 103 | 104 | # pylint: disable=import-outside-toplevel 105 | def lazy_import_sensors(): 106 | """Lazy load the sensor classes.""" 107 | from .sensors.pikvm_cpu_temp_sensor import PiKVMCpuTempSensor 108 | from .sensors.pikvm_cpu_utilization_sensor import PiKVMCpuUtilizationSensor 109 | from .sensors.pikvm_extra_sensor import PiKVMExtraSensor 110 | from .sensors.pikvm_fan_speed_sensor import PiKVMFanSpeedSensor 111 | from .sensors.pikvm_memory_utilization_sensor import PiKVMMemoryUtilizationSensor 112 | from .sensors.pikvm_msd_drive_sensor import PiKVMSDDriveSensor 113 | from .sensors.pikvm_msd_enabled_sensor import PiKVMSDEnabledSensor 114 | from .sensors.pikvm_msd_storage_sensor import PiKVMSDStorageSensor 115 | from .sensors.pikvm_throttling_sensor import PiKVMThrottlingSensor 116 | 117 | return { 118 | "cpu_temp": PiKVMCpuTempSensor, 119 | "cpu_utilization": PiKVMCpuUtilizationSensor, 120 | "extra": PiKVMExtraSensor, 121 | "fan_speed": PiKVMFanSpeedSensor, 122 | "memory_utilization": PiKVMMemoryUtilizationSensor, 123 | "msd_drive": PiKVMSDDriveSensor, 124 | "msd_enabled": PiKVMSDEnabledSensor, 125 | "msd_storage": PiKVMSDStorageSensor, 126 | "throttling": PiKVMThrottlingSensor, 127 | } 128 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/coordinator.py: -------------------------------------------------------------------------------- 1 | """Manages fetching data from the PiKVM API.""" 2 | 3 | import asyncio 4 | from datetime import timedelta 5 | import functools 6 | import logging 7 | import os 8 | 9 | import requests 10 | from requests.auth import HTTPBasicAuth 11 | 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 14 | 15 | from .cert_handler import create_session_with_cert # Import the function 16 | from .const import DOMAIN 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | def format_url(input_url): 22 | """Ensure the URL is properly formatted.""" 23 | if not input_url.startswith("http"): 24 | input_url = f"https://{input_url}" 25 | return input_url.rstrip("/") 26 | 27 | 28 | class AuthenticationFailed(Exception): 29 | """Custom exception for authentication failures.""" 30 | 31 | 32 | class PiKVMDataUpdateCoordinator(DataUpdateCoordinator): 33 | """Class to manage fetching data from the PiKVM API.""" 34 | 35 | url: str = "" 36 | 37 | def __init__( 38 | self, hass: HomeAssistant, url: str, username: str, password: str, cert: str 39 | ) -> None: 40 | """Initialize.""" 41 | self.hass = hass 42 | self.url = format_url(url) 43 | self.username = username 44 | self.password = password 45 | self.cert = cert 46 | self.session = None 47 | self.cert_file_path = None 48 | self.auth = HTTPBasicAuth(self.username, self.password) 49 | super().__init__( 50 | hass, 51 | _LOGGER, 52 | name=DOMAIN, 53 | update_interval=timedelta(seconds=30), 54 | ) 55 | # Create the session initially 56 | 57 | async def async_setup(self) -> None: 58 | """Async setup method to create session and handle async code.""" 59 | # Create the session asynchronously 60 | await self._create_session() 61 | 62 | async def _create_session(self): 63 | """Create the session with the certificate.""" 64 | # self.auth is already defined in __init__ 65 | self.auth = HTTPBasicAuth(self.username, self.password) 66 | session_with_cert = await create_session_with_cert(self.cert) 67 | self.session, self.cert_file_path = session_with_cert 68 | if not self.session: 69 | _LOGGER.error("Failed to create session with certificate") 70 | else: 71 | _LOGGER.debug("Session created successfully") 72 | 73 | async def _async_update_data(self): 74 | """Fetch data from PiKVM API.""" 75 | max_retries = 5 76 | backoff_time = 2 # Initial backoff time in seconds 77 | retries = 0 78 | 79 | while retries < max_retries: 80 | try: 81 | _LOGGER.debug("Fetching PiKVM Info & MSD at %s", self.url) 82 | 83 | if not self.session: 84 | self._create_session() 85 | 86 | response = await self.hass.async_add_executor_job( 87 | functools.partial( 88 | self.session.get, 89 | f"{self.url}/api/info", 90 | auth=self.auth, 91 | timeout=10, 92 | ) 93 | ) 94 | 95 | if response.status_code == 401: 96 | raise AuthenticationFailed("Invalid username or password") # noqa: TRY301 97 | 98 | response.raise_for_status() 99 | data_info = response.json()["result"] 100 | 101 | response_msd = await self.hass.async_add_executor_job( 102 | functools.partial( 103 | self.session.get, 104 | f"{self.url}/api/msd", 105 | auth=self.auth, 106 | timeout=10, 107 | ) 108 | ) 109 | response_msd.raise_for_status() 110 | data_msd = response_msd.json()["result"] 111 | 112 | data_info["msd"] = data_msd 113 | _LOGGER.debug("Received PiKVM Info & MSD from %s", self.url) 114 | 115 | return data_info # noqa: TRY300 116 | except AuthenticationFailed as auth_err: 117 | _LOGGER.error("Authentication failed: %s", auth_err) 118 | raise UpdateFailed(f"Authentication failed: {auth_err}") from auth_err 119 | except requests.exceptions.RequestException as err: 120 | retries += 1 121 | if retries < max_retries: 122 | _LOGGER.warning( 123 | "Error communicating with API: %s. Retrying in %s seconds", 124 | err, 125 | backoff_time, 126 | ) 127 | await asyncio.sleep(backoff_time) 128 | backoff_time *= 2 # Exponential backoff 129 | else: 130 | _LOGGER.error( 131 | "Max retries exceeded. Error communicating with API: %s", err 132 | ) 133 | raise UpdateFailed(f"Error communicating with API: {err}") from err 134 | except (ValueError, KeyError) as e: 135 | _LOGGER.error("Data processing error: %s", e) 136 | raise UpdateFailed(f"Data processing error: {e}") from e 137 | finally: 138 | if self.cert_file_path and os.path.exists(self.cert_file_path): 139 | os.remove(self.cert_file_path) 140 | return None 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PiKVM Integration for Home Assistant 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) 4 | 5 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=adamoutler&repository=pikvm-homeassistant-integration&category=Integration) 6 | 7 | This is a custom integration for Home Assistant to monitor and control PiKVM devices. 8 | 9 | ## Features 10 | 11 | - Monitor CPU temperature 12 | - Monitor fan speed 13 | - Check device throttling status 14 | - Monitor MSD status and storage 15 | - Track additional PiKVM services (IPMI, Janus, VNC, Webterm) 16 | 17 | ## Installation 18 | 19 | ### Automagic Installation 20 | 21 | Use the Home Assitant My link to add this repository to HACS. 22 | 23 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=adamoutler&repository=pikvm-homeassistant-integration&category=Integration) 24 | 25 | ### HACS (Home Assistant Community Store) 26 | 27 | 1. Ensure that HACS is installed and configured in your Home Assistant setup. If not, follow the instructions [here](https://hacs.xyz/docs/installation/manual). 28 | 2. Go to the HACS panel in Home Assistant. 29 | 3. Click on the "Integrations" tab. 30 | 4. Click on the three dots in the top right corner and select "Custom repositories". 31 | 5. Add this repository URL: `https://github.com/adamoutler/pikvm-homeassistant-integration` and select "Integration" as the category. 32 | 6. Find "PiKVM Integration" in the list and click "Install". 33 | 7. Restart Home Assistant. 34 | 35 | ### Manual Installation 36 | 37 | 1. Download the `custom_components` folder from this repository. 38 | 2. Copy the `pikvm` folder into your Home Assistant `custom_components` directory. 39 | 3. Restart Home Assistant. 40 | 41 | ## Configuration 42 | 43 | ### Adding PiKVM Integration via Home Assistant UI 44 | 45 | 1. Go to the Home Assistant UI. 46 | 2. Navigate to **Configuration** -> **Devices & Services**. 47 | 3. Click the **Add Integration** button. 48 | 4. Search for "PiKVM". 49 | 5. Follow the setup wizard to configure your PiKVM device. 50 | 51 | ### Configuration Options 52 | 53 | - **URL**: The URL or IP address of your PiKVM device. 54 | - **Username**: The username to authenticate with your PiKVM device (default: `admin`). 55 | - **Password**: The password to authenticate with your PiKVM device (default: `admin`). 56 | 57 | ## Usage 58 | 59 | Once the PiKVM integration is added and configured, you will have several sensors available in Home Assistant to monitor the status and health of your PiKVM device. These sensors will include CPU temperature, fan speed, MSD status, and more. 60 | 61 | ## Development 62 | 63 | ### Setting up the Development Environment 64 | 65 | 1. **Clone the Repository**: For development purposes, git clone this repository to your `/config` folder. 66 | ```sh 67 | git clone https://github.com/yourusername/pikvm-homeassistant /config/pikvm-homeassistant 68 | ``` 69 | 2. Open with VSCode: Open the repository with VSCode. 70 | 3. Make Your Changes: Make your changes in the repository. 71 | 4. Restart Home Assistant: Restart Home Assistant with F1 -> Tasks: Restart HA. 72 | 5. View Logs: View logs with F1 -> Tasks: logs. 73 | 6. Enable Debug Logging: For higher detail in logs, enable debug logging in the Home Assistant integration. 74 | 75 | ### Running Tests 76 | 77 | 1. (Optional) Create and activate a virtual environment for development. 78 | 2. Install the test dependencies with `pip install -r requirements_test.txt` (add `--break-system-packages` when using the provided dev container). 79 | 3. Execute the test suite with `pytest` from the repository root. 80 | 4. To run a subset, target a path such as `pytest tests/test_config_flow.py`. 81 | 82 | ## Script for Development 83 | 84 | A script is included to automatically link the repository to the correct directory for development. This script will run when you open the workspace. 85 | 86 | Script: `.vscode/scripts/link-repository.sh` 87 | 88 | ``` sh 89 | #!/bin/sh 90 | 91 | # Check if /config/custom_components directory exists 92 | if [ ! -d /config/custom_components ]; then 93 | echo "cannot find custom components directory" 94 | exit 1 95 | fi 96 | 97 | # Check if /config/custom_components/pikvm folder already exists 98 | if [ -d /config/custom_components/pikvm ]; then 99 | echo "/config/custom_components/pikvm folder already exists" 100 | exit 1 101 | fi 102 | 103 | # Unlink /config/custom_components/pikvm if it's a symbolic link 104 | if [ -L /config/custom_components/pikvm ]; then 105 | unlink /config/custom_components/pikvm 106 | fi 107 | 108 | # Check if custom_components directory exists in the current workspace 109 | if [ ! -d custom_components ]; then 110 | echo "this must be run from the root of the pikvm workspace" 111 | exit 1 112 | fi 113 | 114 | # Create symbolic link 115 | ln -s "$(pwd)/custom_components/pikvm" /config/custom_components/pikvm 116 | echo "Linking Successful" 117 | ``` 118 | 119 | ## Troubleshooting 120 | 121 | * Ensure your PiKVM device is accessible from your Home Assistant instance. 122 | * Make sure you have provided the correct URL, username, and password. 123 | * Check the Home Assistant logs for any error messages related to the PiKVM integration. 124 | 125 | ## Contributing 126 | 127 | Contributions are welcome! Please fork this repository and open a pull request with your changes. 128 | 129 | ## License 130 | 131 | This project is licensed under the MIT License - see the LICENSE file for details. 132 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/__init__.py: -------------------------------------------------------------------------------- 1 | """The PiKVM integration.""" 2 | 3 | import asyncio 4 | import logging 5 | 6 | import voluptuous as vol 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | import homeassistant.helpers.config_validation as cv 11 | from homeassistant.helpers.device_registry import DeviceInfo 12 | from homeassistant.helpers.typing import ConfigType 13 | from homeassistant.loader import async_get_integration 14 | 15 | from .cert_handler import format_url 16 | from .const import ( 17 | CONF_CERTIFICATE, 18 | CONF_HOST, 19 | CONF_PASSWORD, 20 | CONF_SERIAL, 21 | CONF_USERNAME, 22 | DEFAULT_PASSWORD, 23 | DEFAULT_USERNAME, 24 | DOMAIN, 25 | MANUFACTURER, 26 | ) 27 | from .coordinator import PiKVMDataUpdateCoordinator 28 | from .sensor import PiKVMEntity 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | # Define a minimal CONFIG_SCHEMA 33 | CONFIG_SCHEMA = vol.Schema( 34 | { 35 | DOMAIN: vol.Schema( 36 | { 37 | vol.Required(CONF_HOST): cv.url, 38 | vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, 39 | vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, 40 | } 41 | ) 42 | }, 43 | extra=vol.ALLOW_EXTRA, 44 | ) 45 | 46 | 47 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 48 | """Set up the PiKVM component.""" 49 | return True 50 | 51 | 52 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 53 | """Set up PiKVM from a config entry. 54 | 55 | This function is responsible for setting up the PiKVM integration in Home Assistant 56 | based on the provided config entry. 57 | 58 | Args: 59 | hass (HomeAssistant): The Home Assistant instance. 60 | entry (ConfigEntry): The config entry for the PiKVM integration. 61 | 62 | Returns: 63 | bool: True if the setup was successful, False otherwise. 64 | 65 | """ 66 | hass.data.setdefault(DOMAIN, {}) 67 | 68 | # Retrieve the unique ID and serial number from the config entry 69 | stored_serial = entry.data.get("serial", None) 70 | unique_id = entry.unique_id 71 | 72 | # Check if the unique ID matches the stored serial number 73 | if stored_serial and unique_id != stored_serial: 74 | _LOGGER.debug("Updating unique ID from %s to %s", unique_id, stored_serial) 75 | hass.config_entries.async_update_entry(entry, unique_id=stored_serial) 76 | 77 | coordinator = PiKVMDataUpdateCoordinator( 78 | hass, 79 | entry.data[CONF_HOST], 80 | entry.data[CONF_USERNAME], 81 | entry.data[CONF_PASSWORD], 82 | entry.data[CONF_CERTIFICATE], # Pass the serialized certificate 83 | ) 84 | await coordinator.async_setup() 85 | await coordinator.async_config_entry_first_refresh() 86 | 87 | hass.data[DOMAIN][entry.entry_id] = coordinator 88 | PiKVMEntity.DEVICE_INFO = DeviceInfo( 89 | identifiers={(DOMAIN, entry.data[CONF_SERIAL])}, 90 | configuration_url=format_url(entry.data[CONF_HOST]), 91 | serial_number=entry.data[CONF_SERIAL], 92 | manufacturer=MANUFACTURER, 93 | name=entry.title, 94 | model=coordinator.data["hw"]["platform"].get("model") 95 | or coordinator.data["hw"]["platform"].get("type"), 96 | hw_version=coordinator.data["hw"]["platform"].get("base"), 97 | sw_version=coordinator.data["system"]["kvmd"].get("version"), 98 | ) 99 | 100 | # Perform the platform setup outside the event loop to avoid blocking 101 | async def forward_platform(): 102 | integration = await async_get_integration(hass, DOMAIN) 103 | # Run tasks concurrently if possible 104 | 105 | await asyncio.gather( 106 | hass.async_add_executor_job(integration.get_platform, "sensor"), 107 | hass.config_entries.async_forward_entry_setups(entry, ["sensor"]), 108 | ) 109 | 110 | hass.async_create_task(forward_platform()) 111 | 112 | entry.async_on_unload(entry.add_update_listener(update_listener)) 113 | 114 | return True 115 | 116 | 117 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 118 | """Unload a config entry. 119 | 120 | This function is responsible for unloading a configuration entry in Home Assistant. 121 | It forwards the entry unload signal to the 'sensor' component and removes the entry from the 'pikvm' domain data. 122 | 123 | Args: 124 | hass (HomeAssistant): The Home Assistant instance. 125 | entry (ConfigEntry): The configuration entry to unload. 126 | 127 | Returns: 128 | bool: True if the entry was successfully unloaded, False otherwise. 129 | 130 | """ 131 | await hass.config_entries.async_forward_entry_unload(entry, "sensor") 132 | hass.data[DOMAIN].pop(entry.entry_id) 133 | 134 | return True 135 | 136 | 137 | async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 138 | """Handle options update.""" 139 | await hass.config_entries.async_reload(entry.entry_id) 140 | 141 | 142 | async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 143 | """Handle removal of a config entry. 144 | 145 | This function is responsible for cleaning up any resources associated with the device 146 | when a configuration entry is removed from Home Assistant. 147 | 148 | Args: 149 | hass (HomeAssistant): The Home Assistant instance. 150 | entry (ConfigEntry): The configuration entry to remove. 151 | """ 152 | # Forward the entry removal signal to the 'sensor' component 153 | await hass.config_entries.async_forward_entry_unload(entry, "sensor") 154 | 155 | # Remove the entry from the 'pikvm' domain data 156 | hass.data[DOMAIN].pop(entry.entry_id, None) 157 | 158 | # Perform any additional cleanup here if necessary 159 | # For example, remove any persistent notifications, stop background tasks, etc. 160 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for the PiKVM integration.""" 2 | 3 | import re 4 | import voluptuous as vol 5 | 6 | from homeassistant.core import HomeAssistant 7 | import logging 8 | from homeassistant import config_entries 9 | from homeassistant.helpers.translation import async_get_translations 10 | from homeassistant.components.zeroconf import ZeroconfServiceInfo 11 | 12 | from .const import ( 13 | CONF_HOST, 14 | CONF_PASSWORD, 15 | CONF_USERNAME, 16 | DEFAULT_PASSWORD, 17 | DEFAULT_USERNAME, 18 | DOMAIN, 19 | ) 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | 25 | def format_url(input_url): 26 | """Ensure the URL is properly formatted.""" 27 | if not input_url.startswith("http"): 28 | input_url = f"https://{input_url}" 29 | return input_url.rstrip("/") 30 | 31 | 32 | def create_data_schema(user_input): 33 | """Create the data schema for the form.""" 34 | return vol.Schema( 35 | { 36 | vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, 37 | vol.Required( 38 | CONF_USERNAME, default=user_input.get(CONF_USERNAME, DEFAULT_USERNAME) 39 | ): str, 40 | vol.Required( 41 | CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, DEFAULT_PASSWORD) 42 | ): str, 43 | } 44 | ) 45 | 46 | 47 | def update_existing_entry(hass: HomeAssistant | None, existing_entry, user_input): 48 | """Update an existing config entry.""" 49 | updated_data = existing_entry.data.copy() 50 | updated_data.update(user_input) 51 | # Ensure the serial number is included in the updated data 52 | if "serial" not in updated_data: 53 | updated_data["serial"] = existing_entry.data.get("serial") 54 | if hass is not None: 55 | hass.config_entries.async_update_entry(existing_entry, data=updated_data) 56 | 57 | 58 | def find_existing_entry(flow_handler, serial) -> config_entries.ConfigEntry | None: 59 | """Find an existing entry with the same serial number.""" 60 | existing_entries = flow_handler._async_current_entries() 61 | for entry in existing_entries: 62 | _LOGGER.debug("Checking existing %s against %s", entry.data.get("serial"), serial) 63 | if entry.data.get("serial").lower() == serial.lower(): 64 | return entry 65 | _LOGGER.debug("No existing entry found for %s, configuring", serial) 66 | return None 67 | 68 | 69 | async def get_translations(hass: HomeAssistant, language, domain): 70 | """Get translations for the given language and domain.""" 71 | if hass is None: 72 | raise ValueError("HomeAssistant instance cannot be None") 73 | translations = await async_get_translations(hass, language, "config") 74 | 75 | def translate(key, default): 76 | return translations.get(f"component.{domain}.{key}", default) 77 | 78 | return translate 79 | 80 | 81 | def get_unique_id_base(config_entry, coordinator): 82 | """Generate the unique_id_base for the sensors.""" 83 | return f"{config_entry.entry_id}_{coordinator.data['hw']['platform']['serial']}" 84 | 85 | 86 | def get_nested_value(data, keys, default=None): 87 | """Safely get a nested value from a dictionary. 88 | 89 | :param data: The dictionary to search. 90 | :param keys: A list of keys to traverse the dictionary. 91 | :param default: The default value to return if the keys are not found. 92 | :return: The value found or the default value. 93 | """ 94 | for key in keys: 95 | data = data.get(key, {}) 96 | return data if data else default 97 | 98 | 99 | def bytes_to_mb(bytes_value): 100 | """Convert bytes to megabytes. 101 | 102 | :param bytes_value: The value in bytes. 103 | :return: The value in megabytes. 104 | """ 105 | return bytes_value / (1024 * 1024) 106 | 107 | 108 | class PiKVMConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 109 | """Handle a config flow for PiKVM.""" 110 | ... 111 | async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> config_entries.ConfigFlowResult: 112 | """Handle the ZeroConf discovery step.""" 113 | serial = discovery_info.properties.get("serial") 114 | host = discovery_info.host 115 | if not serial or not host: 116 | _LOGGER.debug("Discovered device with ZeroConf but missing serial or host") 117 | return self.async_abort(reason="missing_serial_or_host") 118 | # Filter out IPv6 addresses 119 | if host.find(":") != -1: 120 | _LOGGER.debug("Discovered device with ZeroConf but IPv6 address") 121 | return self.async_abort(reason="ipv6_address") 122 | _LOGGER.debug( 123 | "Discovered device with ZeroConf: host=%s, serial=%s, model=%s", 124 | host, 125 | serial, 126 | discovery_info.properties.get("model"), 127 | ) 128 | existing_entry = find_existing_entry(self, serial) 129 | if existing_entry: 130 | _LOGGER.debug( 131 | "Device with serial %s already configured, updating existing entry", 132 | serial, 133 | ) 134 | existing_username = existing_entry.data.get(CONF_USERNAME, DEFAULT_USERNAME) 135 | existing_password = existing_entry.data.get(CONF_PASSWORD, DEFAULT_PASSWORD) 136 | _LOGGER.debug( 137 | "Updating existing entry with host=%s, username=%s, password=%s", 138 | host, 139 | existing_username, 140 | re.sub(r'.', '*', existing_password), 141 | ) 142 | update_existing_entry( 143 | self.hass, 144 | existing_entry, 145 | { 146 | CONF_HOST: host, 147 | CONF_USERNAME: existing_username, 148 | CONF_PASSWORD: existing_password, 149 | "serial": serial, # Ensure serial is included 150 | }, 151 | ) 152 | return self.async_abort(reason="already_configured") 153 | # Offer options to add or ignore 154 | self._discovery_info = { 155 | CONF_HOST: host, 156 | CONF_USERNAME: DEFAULT_USERNAME, 157 | CONF_PASSWORD: DEFAULT_PASSWORD, 158 | "serial": serial, 159 | } 160 | return await self._show_zeroconf_menu() 161 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/options_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow to configure PiKVM.""" 2 | 3 | import logging 4 | 5 | from homeassistant import config_entries 6 | 7 | from .cert_handler import fetch_serialized_cert, is_pikvm_device 8 | from .const import ( 9 | CONF_CERTIFICATE, 10 | CONF_HOST, 11 | CONF_PASSWORD, 12 | CONF_USERNAME, 13 | DEFAULT_PASSWORD, 14 | DEFAULT_USERNAME, 15 | DOMAIN, 16 | MANUFACTURER, 17 | ) 18 | from .utils import ( 19 | create_data_schema, 20 | format_url, 21 | get_translations, 22 | update_existing_entry, 23 | ) 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | class PiKVMOptionsFlowHandler(config_entries.OptionsFlow): 29 | """Handle PiKVM options.""" 30 | 31 | def __init__(self, config_entry) -> None: 32 | """Initialize options flow.""" 33 | self.config_entry = config_entry 34 | self.translate = None 35 | 36 | async def async_step_init(self, user_input=None): 37 | """Manage the PiKVM options.""" 38 | errors = {} 39 | self.translate = await get_translations( 40 | self.hass, self.hass.config.language, DOMAIN 41 | ) 42 | _LOGGER.debug("Entered async_step_init with data: %s", user_input) 43 | 44 | if user_input is not None: 45 | # Validate the new credentials 46 | url = format_url(user_input[CONF_HOST]) 47 | username = user_input.get(CONF_USERNAME, DEFAULT_USERNAME) 48 | password = user_input.get(CONF_PASSWORD, DEFAULT_PASSWORD) 49 | 50 | _LOGGER.debug("Manual setup with URL %s, username %s", url, username) 51 | 52 | serialized_cert = await fetch_serialized_cert(self.hass, url) 53 | if not serialized_cert: 54 | errors["base"] = "cannot_fetch_cert" 55 | _LOGGER.error("Cannot fetch cert from URL: %s", url) 56 | else: 57 | _LOGGER.debug("Serialized certificate: %s", serialized_cert) 58 | user_input[CONF_CERTIFICATE] = serialized_cert 59 | 60 | is_pikvm, serial, name = await is_pikvm_device( 61 | self.hass, url, username, password, serialized_cert 62 | ) 63 | if name is None or name == "localhost.localdomain": 64 | name = DOMAIN 65 | elif name.startswith("Exception_"): 66 | errors["base"] = name 67 | elif is_pikvm: 68 | _LOGGER.debug( 69 | "PiKVM device successfully found at %s with serial %s", 70 | url, 71 | serial, 72 | ) 73 | 74 | existing_entry = None 75 | for entry in self.hass.config_entries.async_entries(DOMAIN): 76 | if entry.unique_id == serial: 77 | existing_entry = entry 78 | break 79 | 80 | if existing_entry: 81 | update_existing_entry(self.hass, existing_entry, user_input) 82 | return self.async_create_entry(title="", data={}) 83 | 84 | user_input["serial"] = serial 85 | new_data = {**self.config_entry.data, **user_input} 86 | self.hass.config_entries.async_update_entry( 87 | self.config_entry, data=new_data 88 | ) 89 | return self.async_create_entry(title="", data={}) 90 | 91 | else: 92 | errors["base"] = "cannot_connect" 93 | _LOGGER.error( 94 | "Cannot connect to PiKVM device at %s with provided credentials", 95 | url, 96 | ) 97 | 98 | # Load existing entry data for reconfiguration 99 | default_url = self.config_entry.data.get(CONF_HOST, "") 100 | default_username = self.config_entry.data.get(CONF_USERNAME, DEFAULT_USERNAME) 101 | default_password = self.config_entry.data.get(CONF_PASSWORD, DEFAULT_PASSWORD) 102 | 103 | data_schema = create_data_schema( 104 | { 105 | CONF_HOST: default_url, 106 | CONF_USERNAME: default_username, 107 | CONF_PASSWORD: default_password, 108 | } 109 | ) 110 | 111 | return self.async_show_form( 112 | step_id="init", 113 | data_schema=data_schema, 114 | errors=errors, 115 | description_placeholders={ 116 | "url": self.translate( 117 | "config.step.user.data.url", "URL or IP address of the PiKVM device" 118 | ), 119 | "username": self.translate( 120 | "config.step.user.data.username", "Username for PiKVM" 121 | ), 122 | "password": self.translate( 123 | "config.step.user.data.password", "Password for PiKVM" 124 | ), 125 | }, 126 | ) 127 | 128 | 129 | async def handle_user_input(self, user_input): 130 | """Handle user input for the configuration.""" 131 | errors = {} 132 | url = format_url(user_input[CONF_HOST]) 133 | user_input[CONF_HOST] = url 134 | 135 | username = user_input.get(CONF_USERNAME, DEFAULT_USERNAME) 136 | password = user_input.get(CONF_PASSWORD, DEFAULT_PASSWORD) 137 | 138 | _LOGGER.debug("Manual setup with URL %s, username %s", url, username) 139 | 140 | serialized_cert = await fetch_serialized_cert(self.hass, url) 141 | if not serialized_cert: 142 | errors["base"] = "cannot_fetch_cert" 143 | return None, errors 144 | 145 | _LOGGER.debug("Serialized certificate: %s", serialized_cert) 146 | user_input[CONF_CERTIFICATE] = serialized_cert 147 | 148 | is_pikvm, serial, name = await is_pikvm_device( 149 | self.hass, url, username, password, serialized_cert 150 | ) 151 | if name is None or name == "localhost.localdomain": 152 | name = DOMAIN 153 | elif name.startswith("Exception_"): 154 | errors["base"] = name 155 | return None, errors 156 | 157 | if is_pikvm: 158 | _LOGGER.debug( 159 | "PiKVM device successfully found at %s with serial %s", url, serial 160 | ) 161 | 162 | existing_entry = None 163 | for entry in self.hass.config_entries.async_entries(DOMAIN): 164 | if entry.unique_id == serial: 165 | existing_entry = entry 166 | break 167 | 168 | if existing_entry: 169 | update_existing_entry(self.hass, existing_entry, user_input) 170 | return self.async_create_entry(title="", data={}) 171 | 172 | user_input["serial"] = serial 173 | await self.async_set_unique_id(serial) 174 | self._abort_if_unique_id_configured() 175 | config_flow_result = self.async_create_entry( 176 | title=name if name else MANUFACTURER, data=user_input 177 | ) 178 | 179 | return config_flow_result, None 180 | _LOGGER.error("Cannot connect to PiKVM device at %s", url) 181 | errors["base"] = "cannot_connect" 182 | return None, errors 183 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/cert_handler.py: -------------------------------------------------------------------------------- 1 | """Handle certificate-related operations for PiKVM integration. 2 | The PiKVM uses non-standard certificates, so we need to handle them manually. 3 | Some of the expected certificatet types are: 4 | - Non-TLSV3 compliant self-signed certificates by default. 5 | - TLSV3 self-signed certificates 6 | - TLSV3 certificates signed by self-signed CA 7 | - TLSV3 certificates signed by a CA 8 | The workarounds found in this cert_handler.py are intended to allow operation 9 | under all circumstances assuming the PiKVM was correctly configured and not 10 | currently being intercepted. The PiKVM public certificate will be recorded 11 | during the time of the initial setup and stored in the Home Assistant configuration. 12 | When loaded, the certificate will be used to establish a secure connection to the PiKVM. 13 | Due to this, we are able to bypass the certificate verification process and establish 14 | a secure connection to the PiKVM. 15 | 16 | This module provides functions to fetch and serialize the certificate from the device. 17 | It also provides a function to check if the device is a PiKVM and return its serial number. 18 | """ 19 | 20 | from collections import namedtuple 21 | import functools 22 | import logging 23 | import os 24 | import socket 25 | import ssl 26 | import tempfile 27 | import warnings 28 | 29 | import OpenSSL 30 | import requests 31 | from requests.adapters import HTTPAdapter 32 | from requests.auth import HTTPBasicAuth 33 | from urllib3.exceptions import InsecureRequestWarning 34 | 35 | from homeassistant.core import HomeAssistant 36 | 37 | from .const import CONF_HOST, CONF_MODEL, CONF_SERIAL 38 | 39 | warnings.simplefilter("ignore", InsecureRequestWarning) 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | 44 | class SSLContextAdapter(HTTPAdapter): 45 | """An HTTP adapter that uses a custom SSL context.""" 46 | 47 | def __init__(self, ssl_context, *args, **kwargs) -> None: 48 | """Initialize the adapter with the custom SSL context. This method is called by the session.""" 49 | self.ssl_context = ssl_context 50 | super().__init__(*args, **kwargs) 51 | 52 | def init_poolmanager(self, *args, **kwargs) -> None: 53 | """Initialize the pool manager with the custom SSL context. This method is called by the session.""" 54 | kwargs["ssl_context"] = self.ssl_context 55 | super().init_poolmanager(*args, **kwargs) 56 | 57 | def cert_verify(self, conn, *args, **kwargs) -> None: 58 | """Disable certificate verification. This method is called by the pool manager.""" 59 | conn.assert_hostname = False 60 | conn.cert_reqs = ssl.CERT_NONE 61 | 62 | 63 | async def create_session_with_cert(hass: HomeAssistant | None, serialized_cert=None): 64 | cert_file_path = None 65 | try: 66 | session = requests.Session() 67 | 68 | # Create an SSL context that disables all verifications 69 | context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 70 | context.check_hostname = False # Disable hostname verification 71 | context.verify_mode = ssl.CERT_NONE # Disable certificate verification 72 | 73 | if serialized_cert: 74 | with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cert_file: 75 | cert_file.write(serialized_cert.encode("utf-8")) 76 | cert_file_path = cert_file.name 77 | if hass is not None: 78 | await hass.async_add_executor_job( 79 | context.load_verify_locations, cert_file_path 80 | ) 81 | else: 82 | context.load_verify_locations(cert_file_path) 83 | 84 | adapter = SSLContextAdapter(context) 85 | session.mount("https://", adapter) 86 | 87 | _LOGGER.debug("Created session with custom SSL context using the certificate") 88 | return session, cert_file_path if serialized_cert else None 89 | except Exception as e: 90 | _LOGGER.error("Error creating session with certificate: %s", e) 91 | return None, None 92 | 93 | 94 | async def fetch_serialized_cert(hass: HomeAssistant, url: str) -> str: 95 | """Fetch and serialize the certificate.""" 96 | return await hass.async_add_executor_job(_fetch_and_serialize_cert, url) 97 | 98 | 99 | def _fetch_and_serialize_cert(url): 100 | """Fetch the certificate from the given URL and serializes it. 101 | 102 | Args: 103 | url (str): The URL from which to fetch the certificate. 104 | 105 | Returns: 106 | str: The serialized certificate in PEM format, or None if an error occurred. 107 | 108 | Raises: 109 | Exception: If there was an error fetching or serializing the certificate. 110 | 111 | """ 112 | try: 113 | hostname = url.replace("https://", "").replace("http://", "").split("/")[0] 114 | port = 443 115 | 116 | context = ssl.create_default_context() 117 | context.check_hostname = False 118 | context.verify_mode = ssl.CERT_NONE 119 | 120 | conn = context.wrap_socket( 121 | socket.socket(socket.AF_INET), server_hostname=hostname 122 | ) 123 | conn.connect((hostname, port)) 124 | 125 | # Get the certificate 126 | cert = conn.getpeercert(True) 127 | x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert) 128 | 129 | # Serialize the certificate 130 | serialized_cert = OpenSSL.crypto.dump_certificate( 131 | OpenSSL.crypto.FILETYPE_PEM, x509 132 | ).decode("utf-8") 133 | conn.close() 134 | return serialized_cert # noqa: TRY300 135 | 136 | except (OSError, ssl.SSLError, OpenSSL.crypto.Error) as e: 137 | _LOGGER.error("Error fetching or serializing certificate from %s: %s", url, e) 138 | if "conn" in locals() and conn: 139 | conn.close() 140 | return None 141 | 142 | 143 | def format_url(input_url): 144 | """Ensure the URL is properly formatted.""" 145 | if not input_url.startswith("http"): 146 | input_url = f"https://{input_url}" 147 | return input_url.rstrip("/") 148 | 149 | 150 | PiKVMResponse = namedtuple( 151 | "PiKVMResponse", ["success", "model", "serial", "name", "error"] 152 | ) 153 | 154 | 155 | async def is_pikvm_device( 156 | hass: HomeAssistant | None, url: str, username: str, password: str, cert: str 157 | ) -> tuple: 158 | """Check if the device is a PiKVM and return its serial number. 159 | 160 | Args: 161 | hass: HomeAssistant instance. 162 | url: The URL of the device. 163 | username: The username for the device. 164 | password: The password for the device. 165 | cert: The certificate for the device. 166 | 167 | Returns: 168 | - A tuple containing the success status, model, serial number, name, and error 169 | code if an error occurred. The error code may contain an HTTP status code. 170 | 171 | """ 172 | url = format_url(url) 173 | _LOGGER.debug("Checking PiKVM device at %s with username %s", url, username) 174 | 175 | try: 176 | if hass is not None: 177 | session, cert_file_path = await create_session_with_cert(hass, cert) 178 | else: 179 | session, cert_file_path = await create_session_with_cert(None, cert) 180 | if not session: 181 | _LOGGER.error("Failed to create session") 182 | return PiKVMResponse(False, None, None, None, "HomeAssistantNoneError") 183 | 184 | if hass is not None: 185 | response = await hass.async_add_executor_job( 186 | functools.partial( 187 | session.get, 188 | f"{url}/api/info", 189 | auth=HTTPBasicAuth(username, password), 190 | ) 191 | ) 192 | else: 193 | response = session.get( 194 | f"{url}/api/info", auth=HTTPBasicAuth(username, password) 195 | ) 196 | 197 | _LOGGER.debug("Received response status code: %s", response.status_code) 198 | response.raise_for_status() 199 | 200 | data = response.json() 201 | _LOGGER.debug("Parsed response JSON: %s", data) 202 | 203 | if data.get("ok", False): 204 | result = data.get("result", {}) 205 | hw = result.get("hw", {}) 206 | platform = hw.get("platform", {}) 207 | meta = result.get("meta", {}) 208 | server = meta.get("server", {}) 209 | 210 | serial = platform.get(CONF_SERIAL, None).lower() 211 | model = platform.get(CONF_MODEL, None) 212 | name = server.get(CONF_HOST, None) 213 | 214 | _LOGGER.debug("Extracted serial number: %s", serial) 215 | return PiKVMResponse(True, model, serial, name, None) 216 | 217 | _LOGGER.error("Device check failed: 'ok' key not present or false") 218 | return PiKVMResponse(False, None, None, None, "GenericException") 219 | 220 | except requests.exceptions.RequestException as err: 221 | # Handle HTTP errors by returning a code which contains the status code 222 | _LOGGER.error("RequestException checking PiKVM device at %s: %s", url, err) 223 | error_code = ( 224 | f"Exception_HTTP{err.response.status_code}" 225 | if err.response 226 | else "Exception_HTTP" 227 | ) 228 | return PiKVMResponse(False, None, None, None, error_code) 229 | 230 | except ValueError as err: 231 | _LOGGER.error("ValueError while parsing response JSON from %s: %s", url, err) 232 | return PiKVMResponse(False, None, None, None, "Exception_JSON") 233 | 234 | finally: 235 | if cert_file_path and os.path.exists(cert_file_path): 236 | try: 237 | os.remove(cert_file_path) 238 | _LOGGER.debug("Temporary certificate file removed: %s", cert_file_path) 239 | except OSError as e: 240 | _LOGGER.warning("Failed to remove temporary certificate file: %s", e) 241 | -------------------------------------------------------------------------------- /custom_components/pikvm_ha/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for PiKVM integration.""" 2 | 3 | import logging 4 | import re 5 | 6 | from homeassistant import config_entries 7 | from homeassistant.components.zeroconf import ZeroconfServiceInfo 8 | from homeassistant.core import callback 9 | 10 | from .cert_handler import fetch_serialized_cert, is_pikvm_device 11 | from .const import ( 12 | CONF_CERTIFICATE, 13 | CONF_HOST, 14 | CONF_MODEL, 15 | CONF_PASSWORD, 16 | CONF_SERIAL, 17 | CONF_USERNAME, 18 | DEFAULT_PASSWORD, 19 | DEFAULT_USERNAME, 20 | DOMAIN, 21 | MANUFACTURER, 22 | ) 23 | from .options_flow import PiKVMOptionsFlowHandler 24 | from .utils import ( 25 | create_data_schema, 26 | find_existing_entry, 27 | get_translations, 28 | update_existing_entry, 29 | ) 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | async def perform_device_setup(flow_handler, user_input): 35 | """Handle initial configuration setup for the configuration.""" 36 | errors = {} 37 | host = user_input[CONF_HOST] 38 | username = user_input[CONF_USERNAME] 39 | password = user_input[CONF_PASSWORD] 40 | 41 | _LOGGER.debug( 42 | "Entered perform_device_setup with URL %s, username %s", host, username 43 | ) 44 | 45 | try: 46 | # Fetch the certificate 47 | serialized_cert = await fetch_serialized_cert(flow_handler.hass, host) 48 | if not serialized_cert: 49 | errors["base"] = "cannot_fetch_cert" 50 | return None, errors 51 | 52 | # Store the certificate 53 | user_input[CONF_CERTIFICATE] = serialized_cert 54 | 55 | # Connect and obtain unique data from the device 56 | response = await is_pikvm_device( 57 | flow_handler.hass, host, username, password, serialized_cert 58 | ) 59 | 60 | if response.error: 61 | errors["base"] = response.error 62 | return None, errors 63 | 64 | if not response.success: 65 | _LOGGER.error( 66 | "Error detected while connecting to PiKVM device. Error: %s", 67 | response.error, 68 | ) 69 | # Handle the error based on response.name_or_error 70 | errors["base"] = "cannot_connect" 71 | return None, errors 72 | 73 | _LOGGER.debug( 74 | "PiKVM device detected: Model=%s, Serial=%s, Name=%s", 75 | response.model, 76 | response.serial, 77 | response.name, 78 | ) 79 | 80 | # Check if the device is already configured now that we obtained serial number 81 | existing_entry = find_existing_entry(flow_handler, response.serial) 82 | if existing_entry: 83 | update_existing_entry( 84 | flow_handler.hass, 85 | existing_entry, 86 | {CONF_HOST: host, CONF_USERNAME: username, CONF_PASSWORD: password}, 87 | ) 88 | return flow_handler.async_abort(reason="already_configured"), None 89 | 90 | device_name = response.name 91 | if device_name == "localhost.localdomain": 92 | device_name = MANUFACTURER 93 | 94 | user_input[CONF_MODEL] = response.model.lower() 95 | user_input[CONF_SERIAL] = response.serial 96 | await flow_handler.async_set_unique_id(response.serial) 97 | 98 | # Finish config 99 | config_flow_result = flow_handler.async_create_entry( 100 | title=device_name if device_name else "PiKVM", data=user_input 101 | ) 102 | return config_flow_result, None # noqa: TRY300 103 | 104 | except (ConnectionError, TimeoutError, ValueError) as e: 105 | _LOGGER.error("Unexpected error during device setup: %s", e) 106 | errors["base"] = "unknown_error" 107 | 108 | return None, errors 109 | 110 | 111 | class PiKVMConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 112 | """Handle a config flow for PiKVM.""" 113 | 114 | VERSION = 1 115 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 116 | 117 | def __init__(self) -> None: 118 | """Initialize the PiKVMConfigFlow.""" 119 | self._errors: dict[str, str] = {} 120 | self.translations = None 121 | self._discovery_info: dict[str, str] = {} 122 | 123 | async def async_step_import( 124 | self, user_input=None 125 | ) -> config_entries.ConfigFlowResult: 126 | """Handle import.""" 127 | return await self.async_step_user(user_input=user_input) 128 | 129 | # lets filter out the zeroconf discovery of ipv6 addresses 130 | async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> config_entries.ConfigFlowResult: 131 | """Handle the ZeroConf discovery step.""" 132 | serial = discovery_info.properties.get("serial").lower() 133 | host = discovery_info.host 134 | if not serial or not host: 135 | _LOGGER.debug("Discovered device with ZeroConf but missing serial or host") 136 | return self.async_abort(reason="missing_serial_or_host") 137 | # Filter out IPv6 addresses 138 | if host.find(":") != -1: 139 | _LOGGER.debug("Discovered device with ZeroConf but IPv6 address") 140 | return self.async_abort(reason="ipv6_address") 141 | _LOGGER.debug( 142 | "Discovered device with ZeroConf: host=%s, serial=%s, model=%s", 143 | host, 144 | serial, 145 | discovery_info.properties.get("model"), 146 | ) 147 | existing_entry = find_existing_entry(self, serial) 148 | if existing_entry: 149 | _LOGGER.debug( 150 | "Device with serial %s already configured, updating existing entry", 151 | serial, 152 | ) 153 | existing_username = existing_entry.data.get(CONF_USERNAME, DEFAULT_USERNAME) 154 | existing_password = existing_entry.data.get(CONF_PASSWORD, DEFAULT_PASSWORD) 155 | _LOGGER.debug( 156 | "Updating existing entry with host=%s, username=%s, password=%s", 157 | host, 158 | existing_username, 159 | re.sub(r'.', '*', existing_password), 160 | ) 161 | update_existing_entry( 162 | self.hass, 163 | existing_entry, 164 | { 165 | CONF_HOST: host, 166 | CONF_USERNAME: existing_username, 167 | CONF_PASSWORD: existing_password, 168 | "serial": serial, # Ensure serial is included 169 | }, 170 | ) 171 | return self.async_abort(reason="already_configured") 172 | # Offer options to add or ignore 173 | self._discovery_info = { 174 | CONF_HOST: host, 175 | CONF_USERNAME: DEFAULT_USERNAME, 176 | CONF_PASSWORD: DEFAULT_PASSWORD, 177 | "serial": serial, 178 | } 179 | return await self._show_zeroconf_menu() 180 | 181 | async def _show_zeroconf_menu(self): 182 | """Show menu for ZeroConf discovered device.""" 183 | return self.async_show_menu( 184 | step_id="zeroconf_confirm", menu_options=["add_device", "ignore"] 185 | ) 186 | 187 | async def async_step_zeroconf_confirm( 188 | self, user_input 189 | ) -> config_entries.ConfigFlowResult: 190 | """Handle confirmation to add or ignore the ZeroConf device.""" 191 | if user_input == "ignore": 192 | _LOGGER.debug( 193 | "Ignoring discovered device with serial %s", 194 | self._discovery_info["serial"], 195 | ) 196 | return self.async_abort(reason="ignored") 197 | 198 | # Proceed with adding the device 199 | entry, errors = await perform_device_setup(self, self._discovery_info) 200 | if entry: 201 | return entry 202 | 203 | self._errors = errors 204 | return await self.async_step_user(user_input=self._discovery_info) 205 | 206 | async def async_step_user(self, user_input=None) -> config_entries.ConfigFlowResult: 207 | """Handle the initial step.""" 208 | errors = self._errors 209 | self._errors = {} # Reset errors after using them 210 | 211 | translations = await get_translations( 212 | self.hass, self.hass.config.language, DOMAIN 213 | ) 214 | if translations and not callable(translations): 215 | 216 | def translate(key: str, default: str) -> str: 217 | return translations.get(key, default) 218 | 219 | self.translations = translate 220 | else: 221 | self.translations = translations 222 | 223 | if user_input is not None: 224 | _LOGGER.debug( 225 | "Entered async_step_user with data: host=%s, username=%s, password=%s", 226 | user_input[CONF_HOST], 227 | user_input[CONF_USERNAME], 228 | re.sub(r'.', '*', user_input[CONF_PASSWORD]), 229 | ) 230 | entry, setup_errors = await perform_device_setup(self, user_input) 231 | if setup_errors: 232 | errors.update(setup_errors) 233 | if entry: 234 | return entry 235 | 236 | if user_input is None: 237 | _LOGGER.debug("Entered async_step_user with data: None") 238 | user_input = self._discovery_info or { 239 | CONF_HOST: "", 240 | CONF_USERNAME: DEFAULT_USERNAME, 241 | CONF_PASSWORD: DEFAULT_PASSWORD, 242 | } 243 | if self._discovery_info: 244 | user_input[CONF_PASSWORD] = "" 245 | 246 | data_schema = create_data_schema(user_input) 247 | 248 | def _translate(key: str, default: str) -> str: 249 | if self.translations: 250 | return self.translations(key, default) 251 | return default 252 | 253 | return self.async_show_form( 254 | step_id="user", 255 | data_schema=data_schema, 256 | errors=errors, 257 | description_placeholders={ 258 | "url": _translate( 259 | "step.user.data.url", "URL or IP address of the PiKVM device" 260 | ), 261 | "username": _translate( 262 | "step.user.data.username", "Username for PiKVM" 263 | ), 264 | "password": _translate( 265 | "step.user.data.password", "Password for PiKVM" 266 | ), 267 | }, 268 | ) 269 | 270 | @staticmethod 271 | @callback 272 | def async_get_options_flow( 273 | config_entry: config_entries.ConfigEntry, 274 | ) -> config_entries.OptionsFlow: 275 | """Create the options flow.""" 276 | return PiKVMOptionsFlowHandler(config_entry) 277 | -------------------------------------------------------------------------------- /tests/test_options_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for the PiKVM options flow.""" 2 | 3 | from unittest.mock import AsyncMock, patch 4 | 5 | import pytest 6 | from homeassistant.data_entry_flow import FlowResultType 7 | from pytest_homeassistant_custom_component.common import MockConfigEntry 8 | 9 | from custom_components.pikvm_ha.const import ( 10 | CONF_CERTIFICATE, 11 | CONF_HOST, 12 | CONF_PASSWORD, 13 | CONF_SERIAL, 14 | CONF_USERNAME, 15 | DEFAULT_PASSWORD, 16 | DEFAULT_USERNAME, 17 | DOMAIN, 18 | ) 19 | from custom_components.pikvm_ha.options_flow import ( 20 | PiKVMOptionsFlowHandler, 21 | handle_user_input, 22 | ) 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_options_flow_updates_entry(hass): 27 | """Ensure that options flow updates credentials and certificate.""" 28 | config_entry = MockConfigEntry( 29 | domain=DOMAIN, 30 | data={ 31 | CONF_HOST: "https://old-host", 32 | CONF_USERNAME: "admin", 33 | CONF_PASSWORD: DEFAULT_PASSWORD, 34 | CONF_SERIAL: "old-serial", 35 | CONF_CERTIFICATE: "old-cert", 36 | }, 37 | ) 38 | config_entry.add_to_hass(hass) 39 | 40 | new_user_input = { 41 | CONF_HOST: "pikvm.local", 42 | CONF_USERNAME: "new_admin", 43 | CONF_PASSWORD: "new_secret", 44 | } 45 | 46 | with patch( 47 | "custom_components.pikvm_ha.options_flow.get_translations", 48 | new=AsyncMock(return_value=lambda key, default: default), 49 | ): 50 | init_result = await hass.config_entries.options.async_init( 51 | config_entry.entry_id 52 | ) 53 | assert init_result["type"] == FlowResultType.FORM 54 | 55 | with ( 56 | patch( 57 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 58 | new=AsyncMock(return_value="new-cert"), 59 | ), 60 | patch( 61 | "custom_components.pikvm_ha.options_flow.is_pikvm_device", 62 | new=AsyncMock(return_value=(True, "pikvm-9999", "My PiKVM")), 63 | ), 64 | ): 65 | result = await hass.config_entries.options.async_configure( 66 | init_result["flow_id"], 67 | user_input=new_user_input, 68 | ) 69 | 70 | assert result["type"] == FlowResultType.CREATE_ENTRY 71 | assert config_entry.data[CONF_HOST] == "pikvm.local" 72 | assert config_entry.data[CONF_USERNAME] == "new_admin" 73 | assert config_entry.data[CONF_PASSWORD] == "new_secret" 74 | assert config_entry.data[CONF_SERIAL] == "pikvm-9999" 75 | assert config_entry.data[CONF_CERTIFICATE] == "new-cert" 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_options_flow_localhost_name_fallback(hass): 80 | """Hostname fallback should not block a successful update.""" 81 | config_entry = MockConfigEntry( 82 | domain=DOMAIN, 83 | data={ 84 | CONF_HOST: "https://old-host", 85 | CONF_USERNAME: "admin", 86 | CONF_PASSWORD: DEFAULT_PASSWORD, 87 | CONF_SERIAL: "old-serial", 88 | CONF_CERTIFICATE: "old-cert", 89 | }, 90 | ) 91 | config_entry.add_to_hass(hass) 92 | 93 | new_user_input = { 94 | CONF_HOST: "pikvm.local", 95 | CONF_USERNAME: "new_admin", 96 | CONF_PASSWORD: "new_secret", 97 | } 98 | 99 | flow = PiKVMOptionsFlowHandler(config_entry) 100 | flow.hass = hass 101 | 102 | with ( 103 | patch( 104 | "custom_components.pikvm_ha.options_flow.get_translations", 105 | new=AsyncMock(return_value=lambda key, default: default), 106 | ), 107 | patch( 108 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 109 | new=AsyncMock(return_value="new-cert"), 110 | ), 111 | patch( 112 | "custom_components.pikvm_ha.options_flow.is_pikvm_device", 113 | new=AsyncMock(return_value=(True, "pikvm-1111", "localhost.localdomain")), 114 | ), 115 | ): 116 | result = await flow.async_step_init(user_input=new_user_input) 117 | 118 | assert result["type"] == FlowResultType.FORM 119 | assert result["errors"] == {} 120 | 121 | 122 | @pytest.mark.asyncio 123 | async def test_options_flow_cannot_fetch_cert(hass): 124 | """Show an error when the certificate cannot be fetched.""" 125 | config_entry = MockConfigEntry( 126 | domain=DOMAIN, 127 | data={ 128 | CONF_HOST: "https://old-host", 129 | CONF_USERNAME: DEFAULT_USERNAME, 130 | CONF_PASSWORD: DEFAULT_PASSWORD, 131 | }, 132 | ) 133 | config_entry.add_to_hass(hass) 134 | 135 | with patch( 136 | "custom_components.pikvm_ha.options_flow.get_translations", 137 | new=AsyncMock(return_value=lambda key, default: default), 138 | ): 139 | init_result = await hass.config_entries.options.async_init( 140 | config_entry.entry_id 141 | ) 142 | 143 | assert init_result["type"] == FlowResultType.FORM 144 | 145 | with patch( 146 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 147 | new=AsyncMock(return_value=None), 148 | ): 149 | result = await hass.config_entries.options.async_configure( 150 | init_result["flow_id"], 151 | user_input={ 152 | CONF_HOST: "pikvm.local", 153 | CONF_USERNAME: "user", 154 | CONF_PASSWORD: "pass", 155 | }, 156 | ) 157 | 158 | assert result["type"] == FlowResultType.FORM 159 | assert result["errors"]["base"] == "cannot_fetch_cert" 160 | 161 | 162 | @pytest.mark.asyncio 163 | async def test_options_flow_exception_error(hass): 164 | """Surface device provided exception codes as form errors.""" 165 | config_entry = MockConfigEntry(domain=DOMAIN, data={}) 166 | config_entry.add_to_hass(hass) 167 | 168 | with patch( 169 | "custom_components.pikvm_ha.options_flow.get_translations", 170 | new=AsyncMock(return_value=lambda key, default: default), 171 | ): 172 | init_result = await hass.config_entries.options.async_init( 173 | config_entry.entry_id 174 | ) 175 | 176 | with ( 177 | patch( 178 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 179 | new=AsyncMock(return_value="cert"), 180 | ), 181 | patch( 182 | "custom_components.pikvm_ha.options_flow.is_pikvm_device", 183 | new=AsyncMock(return_value=(True, "serial", "Exception_error")), 184 | ), 185 | ): 186 | result = await hass.config_entries.options.async_configure( 187 | init_result["flow_id"], 188 | user_input={ 189 | CONF_HOST: "pikvm.local", 190 | CONF_USERNAME: "user", 191 | CONF_PASSWORD: "pass", 192 | }, 193 | ) 194 | 195 | assert result["type"] == FlowResultType.FORM 196 | assert result["errors"]["base"] == "Exception_error" 197 | 198 | 199 | @pytest.mark.asyncio 200 | async def test_options_flow_existing_entry_updates(hass): 201 | """Updating an existing entry should reuse the stored config entry.""" 202 | config_entry = MockConfigEntry(domain=DOMAIN, data={}) 203 | config_entry.add_to_hass(hass) 204 | 205 | existing_entry = MockConfigEntry( 206 | domain=DOMAIN, 207 | unique_id="pikvm-9999", 208 | data={ 209 | CONF_HOST: "https://existing", 210 | CONF_USERNAME: "saved", 211 | CONF_PASSWORD: "secret", 212 | }, 213 | ) 214 | existing_entry.add_to_hass(hass) 215 | 216 | with patch( 217 | "custom_components.pikvm_ha.options_flow.get_translations", 218 | new=AsyncMock(return_value=lambda key, default: default), 219 | ): 220 | init_result = await hass.config_entries.options.async_init( 221 | config_entry.entry_id 222 | ) 223 | 224 | with ( 225 | patch( 226 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 227 | new=AsyncMock(return_value="cert"), 228 | ), 229 | patch( 230 | "custom_components.pikvm_ha.options_flow.is_pikvm_device", 231 | new=AsyncMock(return_value=(True, "pikvm-9999", "My PiKVM")), 232 | ), 233 | patch( 234 | "custom_components.pikvm_ha.options_flow.update_existing_entry", 235 | autospec=True, 236 | ) as update_mock, 237 | ): 238 | result = await hass.config_entries.options.async_configure( 239 | init_result["flow_id"], 240 | user_input={ 241 | CONF_HOST: "pikvm.local", 242 | CONF_USERNAME: "user", 243 | CONF_PASSWORD: "pass", 244 | }, 245 | ) 246 | 247 | assert result["type"] == FlowResultType.CREATE_ENTRY 248 | update_mock.assert_called_once() 249 | 250 | 251 | @pytest.mark.asyncio 252 | async def test_options_flow_cannot_connect(hass): 253 | """Show connection error when device validation fails.""" 254 | config_entry = MockConfigEntry(domain=DOMAIN, data={}) 255 | config_entry.add_to_hass(hass) 256 | 257 | flow = PiKVMOptionsFlowHandler(config_entry) 258 | flow.hass = hass 259 | 260 | with ( 261 | patch( 262 | "custom_components.pikvm_ha.options_flow.get_translations", 263 | new=AsyncMock(return_value=lambda key, default: default), 264 | ), 265 | patch( 266 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 267 | new=AsyncMock(return_value="cert"), 268 | ), 269 | patch( 270 | "custom_components.pikvm_ha.options_flow.is_pikvm_device", 271 | new=AsyncMock(return_value=(False, "SERIAL", "My PiKVM")), 272 | ), 273 | ): 274 | result = await flow.async_step_init( 275 | user_input={ 276 | CONF_HOST: "pikvm.local", 277 | CONF_USERNAME: "user", 278 | CONF_PASSWORD: "pass", 279 | } 280 | ) 281 | 282 | assert result["type"] == FlowResultType.FORM 283 | assert result["errors"]["base"] == "cannot_connect" 284 | 285 | 286 | class _DummyFlow: 287 | """Simple helper to exercise handle_user_input in isolation.""" 288 | 289 | def __init__(self, hass): 290 | self.hass = hass 291 | self.unique_ids = [] 292 | self.created_entries = [] 293 | 294 | async def async_set_unique_id(self, serial): 295 | self.unique_ids.append(serial) 296 | 297 | def _abort_if_unique_id_configured(self): 298 | return None 299 | 300 | def async_create_entry(self, title, data): 301 | entry = {"title": title, "data": data} 302 | self.created_entries.append(entry) 303 | return entry 304 | 305 | 306 | @pytest.mark.asyncio 307 | async def test_handle_user_input_creates_new_entry(hass): 308 | """handle_user_input should create a new entry when validation succeeds.""" 309 | flow = _DummyFlow(hass) 310 | 311 | with ( 312 | patch( 313 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 314 | new=AsyncMock(return_value="cert"), 315 | ), 316 | patch( 317 | "custom_components.pikvm_ha.options_flow.is_pikvm_device", 318 | new=AsyncMock(return_value=(True, "SERIAL123", None)), 319 | ), 320 | patch( 321 | "custom_components.pikvm_ha.options_flow.update_existing_entry" 322 | ) as update_mock, 323 | ): 324 | result, errors = await handle_user_input( 325 | flow, 326 | { 327 | CONF_HOST: "pikvm.local", 328 | CONF_USERNAME: "user", 329 | CONF_PASSWORD: "pass", 330 | }, 331 | ) 332 | 333 | assert errors is None 334 | assert result["data"][CONF_HOST] == "https://pikvm.local" 335 | assert result["data"][CONF_SERIAL] == "SERIAL123" 336 | assert flow.unique_ids == ["SERIAL123"] 337 | update_mock.assert_not_called() 338 | 339 | 340 | @pytest.mark.asyncio 341 | async def test_handle_user_input_existing_entry(hass): 342 | """Existing entries are updated and reused.""" 343 | flow = _DummyFlow(hass) 344 | 345 | existing_entry = MockConfigEntry( 346 | domain=DOMAIN, 347 | unique_id="SERIAL123", 348 | data={ 349 | CONF_HOST: "https://existing", 350 | CONF_USERNAME: "saved", 351 | CONF_PASSWORD: "secret", 352 | }, 353 | ) 354 | existing_entry.add_to_hass(hass) 355 | 356 | with ( 357 | patch( 358 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 359 | new=AsyncMock(return_value="cert"), 360 | ), 361 | patch( 362 | "custom_components.pikvm_ha.options_flow.is_pikvm_device", 363 | new=AsyncMock(return_value=(True, "SERIAL123", "My PiKVM")), 364 | ), 365 | patch( 366 | "custom_components.pikvm_ha.options_flow.update_existing_entry", 367 | autospec=True, 368 | ) as update_mock, 369 | ): 370 | outcome = await handle_user_input( 371 | flow, 372 | { 373 | CONF_HOST: "pikvm.local", 374 | CONF_USERNAME: "user", 375 | CONF_PASSWORD: "pass", 376 | }, 377 | ) 378 | 379 | if isinstance(outcome, tuple): 380 | result, errors = outcome 381 | else: 382 | result, errors = outcome, None 383 | 384 | assert result["data"] == {} 385 | assert errors is None 386 | update_mock.assert_called_once() 387 | 388 | 389 | @pytest.mark.asyncio 390 | async def test_handle_user_input_exception_error(hass): 391 | """Exception-prefixed names surface as errors.""" 392 | flow = _DummyFlow(hass) 393 | 394 | with ( 395 | patch( 396 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 397 | new=AsyncMock(return_value="cert"), 398 | ), 399 | patch( 400 | "custom_components.pikvm_ha.options_flow.is_pikvm_device", 401 | new=AsyncMock(return_value=(True, "SERIAL", "Exception_problem")), 402 | ), 403 | ): 404 | result, errors = await handle_user_input( 405 | flow, 406 | { 407 | CONF_HOST: "pikvm.local", 408 | CONF_USERNAME: "user", 409 | CONF_PASSWORD: "pass", 410 | }, 411 | ) 412 | 413 | assert result is None 414 | assert errors["base"] == "Exception_problem" 415 | 416 | 417 | @pytest.mark.asyncio 418 | async def test_handle_user_input_missing_certificate(hass): 419 | """Missing certificates should abort with an error.""" 420 | flow = _DummyFlow(hass) 421 | 422 | with patch( 423 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 424 | new=AsyncMock(return_value=None), 425 | ): 426 | result, errors = await handle_user_input( 427 | flow, 428 | { 429 | CONF_HOST: "pikvm.local", 430 | CONF_USERNAME: "user", 431 | CONF_PASSWORD: "pass", 432 | }, 433 | ) 434 | 435 | assert result is None 436 | assert errors["base"] == "cannot_fetch_cert" 437 | 438 | 439 | @pytest.mark.asyncio 440 | async def test_handle_user_input_cannot_connect(hass): 441 | """Connection failures should propagate as errors.""" 442 | flow = _DummyFlow(hass) 443 | 444 | with ( 445 | patch( 446 | "custom_components.pikvm_ha.options_flow.fetch_serialized_cert", 447 | new=AsyncMock(return_value="cert"), 448 | ), 449 | patch( 450 | "custom_components.pikvm_ha.options_flow.is_pikvm_device", 451 | new=AsyncMock(return_value=(False, None, None)), 452 | ), 453 | ): 454 | result, errors = await handle_user_input( 455 | flow, 456 | { 457 | CONF_HOST: "pikvm.local", 458 | CONF_USERNAME: "user", 459 | CONF_PASSWORD: "pass", 460 | }, 461 | ) 462 | 463 | assert result is None 464 | assert errors["base"] == "cannot_connect" 465 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for the PiKVM config flow.""" 2 | 3 | from ipaddress import IPv4Address, IPv6Address 4 | from types import SimpleNamespace 5 | from unittest.mock import AsyncMock, MagicMock, patch 6 | 7 | import pytest 8 | from homeassistant import config_entries 9 | from homeassistant.components.zeroconf import ZeroconfServiceInfo 10 | from homeassistant.data_entry_flow import FlowResultType 11 | from pytest_homeassistant_custom_component.common import MockConfigEntry 12 | 13 | from custom_components.pikvm_ha import config_flow 14 | from custom_components.pikvm_ha.cert_handler import PiKVMResponse 15 | from custom_components.pikvm_ha.const import ( 16 | CONF_CERTIFICATE, 17 | CONF_HOST, 18 | CONF_MODEL, 19 | CONF_PASSWORD, 20 | CONF_SERIAL, 21 | CONF_USERNAME, 22 | DEFAULT_PASSWORD, 23 | DEFAULT_USERNAME, 24 | DOMAIN, 25 | MANUFACTURER, 26 | ) 27 | from custom_components.pikvm_ha.options_flow import PiKVMOptionsFlowHandler 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_config_flow_user_success(hass, pikvm_cert): 32 | """Test a full successful user initiated config flow.""" 33 | user_input = { 34 | CONF_HOST: "https://pikvm.local", 35 | CONF_USERNAME: "admin", 36 | CONF_PASSWORD: "secret", 37 | } 38 | 39 | response = PiKVMResponse(True, "v3", "pikvm-1234", "My PiKVM", None) 40 | 41 | with patch( 42 | "custom_components.pikvm_ha.config_flow.fetch_serialized_cert", 43 | new=AsyncMock(return_value=pikvm_cert), 44 | ) as mock_fetch, patch( 45 | "custom_components.pikvm_ha.config_flow.is_pikvm_device", 46 | new=AsyncMock(return_value=response), 47 | ) as mock_is_pikvm: 48 | result = await hass.config_entries.flow.async_init( 49 | DOMAIN, 50 | context={"source": config_entries.SOURCE_USER}, 51 | data=user_input, 52 | ) 53 | 54 | assert result["type"] == FlowResultType.CREATE_ENTRY 55 | assert result["title"] == "My PiKVM" 56 | assert result["data"][CONF_CERTIFICATE] == pikvm_cert 57 | assert result["data"][CONF_SERIAL] == "pikvm-1234" 58 | assert result["data"][CONF_MODEL] == "v3" 59 | mock_fetch.assert_awaited_once() 60 | mock_is_pikvm.assert_awaited_once() 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_config_flow_user_cannot_connect(hass, pikvm_cert): 65 | """Test the user step when the device cannot be reached.""" 66 | user_input = { 67 | CONF_HOST: "https://pikvm.local", 68 | CONF_USERNAME: "admin", 69 | CONF_PASSWORD: "secret", 70 | } 71 | 72 | failure = PiKVMResponse(False, None, None, None, "cannot_connect") 73 | 74 | with patch( 75 | "custom_components.pikvm_ha.config_flow.fetch_serialized_cert", 76 | new=AsyncMock(return_value=pikvm_cert), 77 | ), patch( 78 | "custom_components.pikvm_ha.config_flow.is_pikvm_device", 79 | new=AsyncMock(return_value=failure), 80 | ): 81 | result = await hass.config_entries.flow.async_init( 82 | DOMAIN, 83 | context={"source": config_entries.SOURCE_USER}, 84 | data=user_input, 85 | ) 86 | 87 | assert result["type"] == FlowResultType.FORM 88 | assert result["errors"]["base"] == "cannot_connect" 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_config_flow_user_initial_form(hass): 93 | """Ensure the initial user form is shown with translation fallbacks.""" 94 | translator = lambda key, default: default 95 | 96 | with patch( 97 | "custom_components.pikvm_ha.config_flow.get_translations", 98 | new=AsyncMock(return_value=translator), 99 | ): 100 | result = await hass.config_entries.flow.async_init( 101 | DOMAIN, 102 | context={"source": config_entries.SOURCE_USER}, 103 | ) 104 | 105 | assert result["type"] == FlowResultType.FORM 106 | assert result["errors"] == {} 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_config_flow_user_discovery_retry_shows_form(hass, pikvm_cert): 111 | """Verify zeroconf discovery with retry surfaces the user form with errors.""" 112 | flow = config_flow.PiKVMConfigFlow() 113 | flow.hass = hass 114 | flow._discovery_info = { 115 | CONF_HOST: "https://pikvm.local", 116 | CONF_USERNAME: DEFAULT_USERNAME, 117 | CONF_PASSWORD: DEFAULT_PASSWORD, 118 | "serial": "SERIAL123", 119 | } 120 | 121 | with ( 122 | patch( 123 | "custom_components.pikvm_ha.config_flow.perform_device_setup", 124 | new=AsyncMock(return_value=(None, {"base": "cannot_connect"})), 125 | ), 126 | patch( 127 | "custom_components.pikvm_ha.config_flow.get_translations", 128 | new=AsyncMock(return_value=lambda key, default: default), 129 | ), 130 | ): 131 | result = await flow.async_step_zeroconf_confirm("add_device") 132 | 133 | assert result["type"] == FlowResultType.FORM 134 | assert result["errors"]["base"] == "cannot_connect" 135 | 136 | 137 | @pytest.mark.asyncio 138 | async def test_perform_device_setup_missing_certificate(hass): 139 | """Ensure we surface an error when no certificate can be retrieved.""" 140 | flow = config_flow.PiKVMConfigFlow() 141 | flow.hass = hass 142 | 143 | user_input = { 144 | CONF_HOST: "https://pikvm.local", 145 | CONF_USERNAME: "admin", 146 | CONF_PASSWORD: "secret", 147 | } 148 | 149 | with patch( 150 | "custom_components.pikvm_ha.config_flow.fetch_serialized_cert", 151 | new=AsyncMock(return_value=None), 152 | ): 153 | entry, errors = await config_flow.perform_device_setup(flow, user_input) 154 | 155 | assert entry is None 156 | assert errors["base"] == "cannot_fetch_cert" 157 | 158 | 159 | @pytest.mark.asyncio 160 | async def test_perform_device_setup_existing_entry_updates(hass, pikvm_cert): 161 | """Existing entries should be updated and abort the flow.""" 162 | flow = config_flow.PiKVMConfigFlow() 163 | flow.hass = hass 164 | flow.async_abort = MagicMock(return_value={"type": FlowResultType.ABORT}) 165 | flow.async_set_unique_id = AsyncMock() 166 | flow.async_create_entry = MagicMock() 167 | 168 | existing_entry = MagicMock() 169 | existing_entry.data = { 170 | CONF_USERNAME: "saved", 171 | CONF_PASSWORD: "s3cret", 172 | "serial": "pikvm-serial", 173 | } 174 | 175 | with ( 176 | patch( 177 | "custom_components.pikvm_ha.config_flow.fetch_serialized_cert", 178 | new=AsyncMock(return_value=pikvm_cert), 179 | ), 180 | patch( 181 | "custom_components.pikvm_ha.config_flow.is_pikvm_device", 182 | new=AsyncMock( 183 | return_value=PiKVMResponse( 184 | True, "V3", "pikvm-serial", "My PiKVM", None 185 | ) 186 | ), 187 | ), 188 | patch( 189 | "custom_components.pikvm_ha.config_flow.find_existing_entry", 190 | return_value=existing_entry, 191 | ), 192 | patch( 193 | "custom_components.pikvm_ha.config_flow.update_existing_entry", 194 | autospec=True, 195 | ) as update_mock, 196 | ): 197 | entry, errors = await config_flow.perform_device_setup( 198 | flow, 199 | { 200 | CONF_HOST: "https://pikvm.local", 201 | CONF_USERNAME: "admin", 202 | CONF_PASSWORD: "secret", 203 | }, 204 | ) 205 | 206 | assert entry == {"type": FlowResultType.ABORT} 207 | assert errors is None 208 | update_mock.assert_called_once() 209 | 210 | 211 | @pytest.mark.asyncio 212 | async def test_perform_device_setup_unknown_error(hass): 213 | """Connection failures bubble up as unknown errors.""" 214 | flow = config_flow.PiKVMConfigFlow() 215 | flow.hass = hass 216 | 217 | with patch( 218 | "custom_components.pikvm_ha.config_flow.fetch_serialized_cert", 219 | new=AsyncMock(side_effect=ConnectionError), 220 | ): 221 | entry, errors = await config_flow.perform_device_setup( 222 | flow, 223 | { 224 | CONF_HOST: "https://pikvm.local", 225 | CONF_USERNAME: "admin", 226 | CONF_PASSWORD: "secret", 227 | }, 228 | ) 229 | 230 | assert entry is None 231 | assert errors["base"] == "unknown_error" 232 | 233 | 234 | @pytest.mark.asyncio 235 | async def test_perform_device_setup_cannot_connect_without_error(hass, pikvm_cert): 236 | """Handle responses that fail without providing an explicit error code.""" 237 | flow = config_flow.PiKVMConfigFlow() 238 | flow.hass = hass 239 | 240 | with ( 241 | patch( 242 | "custom_components.pikvm_ha.config_flow.fetch_serialized_cert", 243 | new=AsyncMock(return_value=pikvm_cert), 244 | ), 245 | patch( 246 | "custom_components.pikvm_ha.config_flow.is_pikvm_device", 247 | new=AsyncMock( 248 | return_value=PiKVMResponse(False, None, None, None, None) 249 | ), 250 | ), 251 | ): 252 | entry, errors = await config_flow.perform_device_setup( 253 | flow, 254 | { 255 | CONF_HOST: "https://pikvm.local", 256 | CONF_USERNAME: "admin", 257 | CONF_PASSWORD: "secret", 258 | }, 259 | ) 260 | 261 | assert entry is None 262 | assert errors["base"] == "cannot_connect" 263 | 264 | 265 | @pytest.mark.asyncio 266 | async def test_perform_device_setup_localhost_name(hass, pikvm_cert): 267 | """Ensure localhost device names fall back to the manufacturer label.""" 268 | flow = config_flow.PiKVMConfigFlow() 269 | flow.hass = hass 270 | flow.async_abort = MagicMock() 271 | flow.async_set_unique_id = AsyncMock() 272 | flow.async_create_entry = MagicMock( 273 | return_value={"type": FlowResultType.CREATE_ENTRY} 274 | ) 275 | 276 | user_input = { 277 | CONF_HOST: "https://pikvm.local", 278 | CONF_USERNAME: "admin", 279 | CONF_PASSWORD: "secret", 280 | } 281 | 282 | with ( 283 | patch( 284 | "custom_components.pikvm_ha.config_flow.fetch_serialized_cert", 285 | new=AsyncMock(return_value=pikvm_cert), 286 | ), 287 | patch( 288 | "custom_components.pikvm_ha.config_flow.is_pikvm_device", 289 | new=AsyncMock( 290 | return_value=SimpleNamespace( 291 | success=True, 292 | model="V4PLUS", 293 | serial="pikvm-9999", 294 | name="localhost.localdomain", 295 | error=None, 296 | ) 297 | ), 298 | ), 299 | patch( 300 | "custom_components.pikvm_ha.config_flow.find_existing_entry", 301 | return_value=None, 302 | ), 303 | ): 304 | entry, errors = await config_flow.perform_device_setup(flow, user_input) 305 | 306 | assert entry == {"type": FlowResultType.CREATE_ENTRY} 307 | flow.async_create_entry.assert_called_once() 308 | kwargs = flow.async_create_entry.call_args.kwargs 309 | assert kwargs["title"] == MANUFACTURER 310 | assert kwargs["data"][CONF_MODEL] == "v4plus" 311 | assert kwargs["data"][CONF_SERIAL] == "pikvm-9999" 312 | assert errors is None 313 | 314 | 315 | @pytest.mark.asyncio 316 | async def test_async_step_import_creates_entry(hass, pikvm_cert): 317 | """Importing from configuration.yaml should reuse the user step.""" 318 | response = PiKVMResponse(True, "V4PLUS", "pikvm-5555", None, None) 319 | 320 | with ( 321 | patch( 322 | "custom_components.pikvm_ha.config_flow.fetch_serialized_cert", 323 | new=AsyncMock(return_value=pikvm_cert), 324 | ), 325 | patch( 326 | "custom_components.pikvm_ha.config_flow.is_pikvm_device", 327 | new=AsyncMock(return_value=response), 328 | ), 329 | patch( 330 | "custom_components.pikvm_ha.config_flow.find_existing_entry", 331 | return_value=None, 332 | ), 333 | ): 334 | result = await hass.config_entries.flow.async_init( 335 | DOMAIN, 336 | context={"source": config_entries.SOURCE_IMPORT}, 337 | data={ 338 | CONF_HOST: "https://pikvm.local", 339 | CONF_USERNAME: "admin", 340 | CONF_PASSWORD: "secret", 341 | }, 342 | ) 343 | 344 | assert result["type"] == FlowResultType.CREATE_ENTRY 345 | assert result["title"] == "PiKVM" 346 | assert result["data"][CONF_MODEL] == "v4plus" 347 | 348 | 349 | def test_async_get_options_flow_returns_handler(): 350 | """Validate the options flow factory.""" 351 | entry = MockConfigEntry(domain=DOMAIN, data={}) 352 | handler = config_flow.PiKVMConfigFlow.async_get_options_flow(entry) 353 | assert isinstance(handler, PiKVMOptionsFlowHandler) 354 | 355 | 356 | @pytest.mark.asyncio 357 | async def test_async_step_zeroconf_missing_serial(hass): 358 | """Abort zeroconf discovery when required data is missing.""" 359 | discovery = ZeroconfServiceInfo( 360 | ip_address=IPv4Address("192.168.1.8"), 361 | ip_addresses=[IPv4Address("192.168.1.8")], 362 | port=443, 363 | hostname="pikvm.local", 364 | type="_http._tcp.local.", 365 | name="pikvm._http._tcp.local.", 366 | properties={"serial": "", "model": "v3"}, 367 | ) 368 | 369 | with patch( 370 | "custom_components.pikvm_ha.config_flow.find_existing_entry", 371 | return_value=None, 372 | ): 373 | result = await hass.config_entries.flow.async_init( 374 | DOMAIN, 375 | context={"source": config_entries.SOURCE_ZEROCONF}, 376 | data=discovery, 377 | ) 378 | 379 | assert result["type"] == FlowResultType.ABORT 380 | assert result["reason"] == "missing_serial_or_host" 381 | 382 | 383 | @pytest.mark.asyncio 384 | async def test_async_step_zeroconf_ipv6_address(hass): 385 | """Abort Zeroconf discovery for IPv6 addresses.""" 386 | discovery = ZeroconfServiceInfo( 387 | ip_address=IPv6Address("fe80::1"), 388 | ip_addresses=[IPv6Address("fe80::1")], 389 | port=443, 390 | hostname="pikvm.local", 391 | type="_http._tcp.local.", 392 | name="pikvm._http._tcp.local.", 393 | properties={"serial": "SERIAL"}, 394 | ) 395 | 396 | with patch( 397 | "custom_components.pikvm_ha.config_flow.find_existing_entry", 398 | return_value=None, 399 | ): 400 | result = await hass.config_entries.flow.async_init( 401 | DOMAIN, 402 | context={"source": config_entries.SOURCE_ZEROCONF}, 403 | data=discovery, 404 | ) 405 | 406 | assert result["type"] == FlowResultType.ABORT 407 | assert result["reason"] == "ipv6_address" 408 | 409 | 410 | @pytest.mark.asyncio 411 | async def test_async_step_zeroconf_existing_entry(hass): 412 | """Existing entries found via Zeroconf are updated.""" 413 | existing_entry = MagicMock() 414 | existing_entry.data = { 415 | CONF_USERNAME: "admin", 416 | CONF_PASSWORD: "secret", 417 | "serial": "serial", 418 | } 419 | 420 | discovery = ZeroconfServiceInfo( 421 | ip_address=IPv4Address("192.168.1.9"), 422 | ip_addresses=[IPv4Address("192.168.1.9")], 423 | port=443, 424 | hostname="pikvm.local", 425 | type="_http._tcp.local.", 426 | name="pikvm._http._tcp.local.", 427 | properties={"serial": "SERIAL", "model": "v3"}, 428 | ) 429 | 430 | with ( 431 | patch( 432 | "custom_components.pikvm_ha.config_flow.find_existing_entry", 433 | return_value=existing_entry, 434 | ), 435 | patch( 436 | "custom_components.pikvm_ha.config_flow.update_existing_entry", 437 | autospec=True, 438 | ) as update_mock, 439 | ): 440 | result = await hass.config_entries.flow.async_init( 441 | DOMAIN, 442 | context={"source": config_entries.SOURCE_ZEROCONF}, 443 | data=discovery, 444 | ) 445 | 446 | assert result["type"] == FlowResultType.ABORT 447 | assert result["reason"] == "already_configured" 448 | update_mock.assert_called_once() 449 | 450 | 451 | @pytest.mark.asyncio 452 | async def test_async_step_zeroconf_new_device_menu(hass): 453 | """New Zeroconf discoveries prompt a confirmation menu.""" 454 | discovery = ZeroconfServiceInfo( 455 | ip_address=IPv4Address("192.168.1.10"), 456 | ip_addresses=[IPv4Address("192.168.1.10")], 457 | port=443, 458 | hostname="pikvm.local", 459 | type="_http._tcp.local.", 460 | name="pikvm._http._tcp.local.", 461 | properties={"serial": "SERIAL", "model": "v3"}, 462 | ) 463 | 464 | with patch( 465 | "custom_components.pikvm_ha.config_flow.find_existing_entry", 466 | return_value=None, 467 | ): 468 | result = await hass.config_entries.flow.async_init( 469 | DOMAIN, 470 | context={"source": config_entries.SOURCE_ZEROCONF}, 471 | data=discovery, 472 | ) 473 | 474 | assert result["type"] == FlowResultType.MENU 475 | assert result["step_id"] == "zeroconf_confirm" 476 | 477 | 478 | @pytest.mark.asyncio 479 | async def test_async_step_zeroconf_confirm_ignore(hass): 480 | """Ignoring a discovered device aborts the flow.""" 481 | flow = config_flow.PiKVMConfigFlow() 482 | flow.hass = hass 483 | flow._discovery_info = { 484 | CONF_HOST: "https://pikvm.local", 485 | CONF_USERNAME: DEFAULT_USERNAME, 486 | CONF_PASSWORD: DEFAULT_PASSWORD, 487 | "serial": "SERIAL", 488 | } 489 | 490 | result = await flow.async_step_zeroconf_confirm("ignore") 491 | 492 | assert result["type"] == FlowResultType.ABORT 493 | assert result["reason"] == "ignored" 494 | 495 | 496 | @pytest.mark.asyncio 497 | async def test_async_step_zeroconf_confirm_success(hass, pikvm_cert): 498 | """Confirming a Zeroconf device proceeds with setup.""" 499 | flow = config_flow.PiKVMConfigFlow() 500 | flow.hass = hass 501 | flow._discovery_info = { 502 | CONF_HOST: "https://pikvm.local", 503 | CONF_USERNAME: DEFAULT_USERNAME, 504 | CONF_PASSWORD: DEFAULT_PASSWORD, 505 | "serial": "SERIAL", 506 | } 507 | 508 | with patch( 509 | "custom_components.pikvm_ha.config_flow.perform_device_setup", 510 | new=AsyncMock(return_value=({"type": FlowResultType.CREATE_ENTRY}, None)), 511 | ): 512 | entry = await flow.async_step_zeroconf_confirm("add_device") 513 | 514 | assert entry["type"] == FlowResultType.CREATE_ENTRY 515 | 516 | 517 | @pytest.mark.asyncio 518 | async def test_async_step_user_with_translation_dict(hass): 519 | """Ensure dictionary-based translations flow through placeholders.""" 520 | translations = { 521 | "step.user.data.url": "Translated URL", 522 | "step.user.data.username": "Translated Username", 523 | "step.user.data.password": "Translated Password", 524 | } 525 | 526 | with patch( 527 | "custom_components.pikvm_ha.config_flow.get_translations", 528 | new=AsyncMock(return_value=translations), 529 | ): 530 | result = await hass.config_entries.flow.async_init( 531 | DOMAIN, 532 | context={"source": config_entries.SOURCE_USER}, 533 | ) 534 | 535 | assert result["type"] == FlowResultType.FORM 536 | placeholders = result["description_placeholders"] 537 | assert placeholders["url"] == "Translated URL" 538 | assert placeholders["username"] == "Translated Username" 539 | assert placeholders["password"] == "Translated Password" 540 | 541 | 542 | @pytest.mark.asyncio 543 | async def test_async_step_user_without_translations_uses_defaults(hass): 544 | """Default placeholders should be provided when translations are unavailable.""" 545 | 546 | with patch( 547 | "custom_components.pikvm_ha.config_flow.get_translations", 548 | new=AsyncMock(return_value=None), 549 | ): 550 | result = await hass.config_entries.flow.async_init( 551 | DOMAIN, 552 | context={"source": config_entries.SOURCE_USER}, 553 | ) 554 | 555 | assert result["type"] == FlowResultType.FORM 556 | placeholders = result["description_placeholders"] 557 | assert placeholders["url"] == "URL or IP address of the PiKVM device" 558 | assert placeholders["username"] == "Username for PiKVM" 559 | assert placeholders["password"] == "Password for PiKVM" 560 | 561 | 562 | @pytest.mark.asyncio 563 | async def test_async_step_user_discovery_password_cleared(hass): 564 | """Discovery-sourced flows should blank passwords before showing the form.""" 565 | flow = config_flow.PiKVMConfigFlow() 566 | flow.hass = hass 567 | flow._discovery_info = { 568 | CONF_HOST: "https://pikvm.local", 569 | CONF_USERNAME: DEFAULT_USERNAME, 570 | CONF_PASSWORD: "secret", 571 | } 572 | 573 | with patch( 574 | "custom_components.pikvm_ha.config_flow.get_translations", 575 | new=AsyncMock(return_value=lambda key, default: default), 576 | ): 577 | result = await flow.async_step_user() 578 | 579 | assert result["type"] == FlowResultType.FORM 580 | assert flow._discovery_info[CONF_PASSWORD] == "" 581 | --------------------------------------------------------------------------------