Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ homeassistant.components.persistent_notification.*
homeassistant.components.person.*
homeassistant.components.philips_infrared.*
homeassistant.components.pi_hole.*
homeassistant.components.pikvm.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
homeassistant.components.pooldose.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions homeassistant/components/pikvm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""The PiKVM integration."""

from homeassistant.const import Platform
from homeassistant.core import HomeAssistant

from .coordinator import PiKVMConfigEntry, PiKVMDataUpdateCoordinator, create_client

PLATFORMS = [Platform.SENSOR]


async def async_setup_entry(hass: HomeAssistant, entry: PiKVMConfigEntry) -> None:
"""Set up PiKVM from a config entry."""
coordinator = PiKVMDataUpdateCoordinator(
hass, entry, create_client(hass, entry.data)
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)


async def async_unload_entry(hass: HomeAssistant, entry: PiKVMConfigEntry) -> None:
"""Unload a PiKVM config entry."""
await hass.config_entries.async_unload_platforms_or_raise(entry, PLATFORMS)
82 changes: 82 additions & 0 deletions homeassistant/components/pikvm/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Config flow for the PiKVM integration."""

from typing import Any, override

from pikvm_aio import PiKVMAuthenticationError, PiKVMDeviceInfo, PiKVMError
import probatio

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL

from .const import CONF_TOTP, DEFAULT_USERNAME, DOMAIN, LOGGER
from .coordinator import create_client

STEP_USER_DATA_SCHEMA = probatio.Schema(
{
probatio.Required(CONF_HOST): str,
probatio.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
probatio.Required(probatio.Secret(CONF_PASSWORD)): str,
probatio.Optional(probatio.Secret(CONF_TOTP)): str,
probatio.Required(CONF_VERIFY_SSL, default=False): bool,
}
)


class PiKVMConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for PiKVM."""

VERSION = 1

async def _async_validate_input(
self, data: dict[str, Any], errors: dict[str, str]
) -> PiKVMDeviceInfo | None:
"""Return the device info, or None and fill in errors."""
# A one-time 6 or 8 digit code would work once and then expire.
totp = data.get(CONF_TOTP, "").strip().replace(" ", "")
if len(totp) in (6, 8) and totp.isdigit():
errors[CONF_TOTP] = "invalid_totp"
return None
try:
client = create_client(self.hass, data)
except PiKVMAuthenticationError:
errors[CONF_TOTP] = "invalid_totp"
return None
except ValueError:
errors[CONF_HOST] = "invalid_host"
return None
try:
return await client.get_info()
except PiKVMAuthenticationError:
errors["base"] = "invalid_auth"
except PiKVMError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return None

@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}

if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if info := await self._async_validate_input(user_input, errors):
if not info.serial:
return self.async_abort(reason="missing_serial")
await self.async_set_unique_id(info.serial)
self._abort_if_unique_id_configured(
updates={CONF_HOST: user_input[CONF_HOST]}
)
return self.async_create_entry(title=info.name, data=user_input)

return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
)
11 changes: 11 additions & 0 deletions homeassistant/components/pikvm/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Constants for the PiKVM integration."""

import logging

DOMAIN = "pikvm"
LOGGER = logging.getLogger(__package__)

MANUFACTURER = "PiKVM"
DEFAULT_USERNAME = "admin"

CONF_TOTP = "totp"
75 changes: 75 additions & 0 deletions homeassistant/components/pikvm/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Data update coordinator for the PiKVM integration."""

from collections.abc import Mapping
from datetime import timedelta
from typing import Any, override

from pikvm_aio import PiKVMAuthenticationError, PiKVMClient, PiKVMDeviceInfo, PiKVMError

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.ssl import get_default_context, get_default_no_verify_context

from .const import CONF_TOTP, DOMAIN, LOGGER

type PiKVMConfigEntry = ConfigEntry[PiKVMDataUpdateCoordinator]


def create_client(hass: HomeAssistant, data: Mapping[str, Any]) -> PiKVMClient:
"""Create a PiKVM API client from config entry data."""
host = data[CONF_HOST].strip()
if "://" in host and not host.lower().startswith("https://"):
raise ValueError("Only HTTPS URLs or bare hostnames are supported")
# Stock PiKVM certificates are self-signed, so verification is opt-in.
verify_ssl = data.get(CONF_VERIFY_SSL, False)
return PiKVMClient(
host=host,
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
totp_secret=data.get(CONF_TOTP) or None,
session=async_get_clientsession(hass, verify_ssl=verify_ssl),
ssl_context=(
get_default_context() if verify_ssl else get_default_no_verify_context()
),
)


class PiKVMDataUpdateCoordinator(DataUpdateCoordinator[PiKVMDeviceInfo]):
"""Class to manage fetching PiKVM data."""

config_entry: PiKVMConfigEntry

def __init__(
self,
hass: HomeAssistant,
config_entry: PiKVMConfigEntry,
client: PiKVMClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=30),
)
self.client = client

@override
async def _async_update_data(self) -> PiKVMDeviceInfo:
try:
return await self.client.get_info()
except PiKVMAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
) from err
except PiKVMError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
) from err
43 changes: 43 additions & 0 deletions homeassistant/components/pikvm/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Base entity for the PiKVM integration."""

from typing import TYPE_CHECKING

from pikvm_aio import format_url

from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN, MANUFACTURER
from .coordinator import PiKVMDataUpdateCoordinator


class PiKVMEntity(CoordinatorEntity[PiKVMDataUpdateCoordinator]):
"""Base entity for PiKVM."""

_attr_has_entity_name = True

def __init__(
self,
coordinator: PiKVMDataUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Initialize the PiKVM entity."""
super().__init__(coordinator)
serial_number = coordinator.config_entry.unique_id
if TYPE_CHECKING:
assert serial_number
info = coordinator.data
self.entity_description = description
self._attr_unique_id = f"{serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
manufacturer=MANUFACTURER,
model=info.model,
name=info.name,
serial_number=serial_number,
hw_version=info.hw.platform.base or None,
sw_version=info.kvmd_version,
configuration_url=format_url(coordinator.config_entry.data[CONF_HOST]),
)
13 changes: 13 additions & 0 deletions homeassistant/components/pikvm/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"domain": "pikvm",
"name": "PiKVM",
"categories": ["computers_network.monitoring"],
"codeowners": ["@adamoutler"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/pikvm",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pikvm_aio"],
"quality_scale": "bronze",
"requirements": ["pikvm-aio==0.1.5"]
}
80 changes: 80 additions & 0 deletions homeassistant/components/pikvm/quality_scale.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-conditions:
status: exempt
comment: Integration does not provide conditions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-triggers:
status: exempt
comment: Integration does not provide triggers.
entity-event-setup:
status: exempt
comment: Entities use the coordinator and do not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done

# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done

# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Each config entry represents a single PiKVM device.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: Each config entry represents a single PiKVM device.

# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
Loading
Loading