From 7caa00084dd8b185891ec52cdf1fe3d8a5ad37fc Mon Sep 17 00:00:00 2001 From: MichaelB2018 Date: Tue, 17 Mar 2026 10:20:08 -0400 Subject: [PATCH 1/2] feat: Add config flow, SSDP discovery, coordinator, sensors v2.0.0 - Added UI config flow with manual setup and SSDP auto-discovery - Added options flow for configurable scan interval and motion timeout - Added DataUpdateCoordinator for centralized polling - Added temperature sensor and firmware version diagnostic sensor - Added diagnostics support with sensitive data redaction - Added device info, entity base class, and English translations - Migrated from legacy platform setup to modern integration architecture - Automatic migration from YAML configuration to config entries - Updated README, CHANGELOG, and hacs.json - Bumped minimum Home Assistant version to 2024.4.0 --- CHANGELOG.md | 20 ++ README.md | 184 ++++++++-- custom_components/dlink_hnap/__init__.py | 71 ++++ custom_components/dlink_hnap/binary_sensor.py | 197 ++++------ custom_components/dlink_hnap/config_flow.py | 218 +++++++++++ custom_components/dlink_hnap/const.py | 41 +++ custom_components/dlink_hnap/coordinator.py | 96 +++++ custom_components/dlink_hnap/diagnostics.py | 33 ++ custom_components/dlink_hnap/dlink.py | 338 ++++++++++++------ custom_components/dlink_hnap/entity.py | 33 ++ custom_components/dlink_hnap/manifest.json | 13 +- custom_components/dlink_hnap/sensor.py | 91 +++++ custom_components/dlink_hnap/strings.json | 61 ++++ .../dlink_hnap/translations/en.json | 61 ++++ hacs.json | 9 +- 15 files changed, 1196 insertions(+), 270 deletions(-) create mode 100644 custom_components/dlink_hnap/config_flow.py create mode 100644 custom_components/dlink_hnap/const.py create mode 100644 custom_components/dlink_hnap/coordinator.py create mode 100644 custom_components/dlink_hnap/diagnostics.py create mode 100644 custom_components/dlink_hnap/entity.py create mode 100644 custom_components/dlink_hnap/sensor.py create mode 100644 custom_components/dlink_hnap/strings.json create mode 100644 custom_components/dlink_hnap/translations/en.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d151617..069fba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +**Version 2.0.0** + +* Added UI config flow with manual setup and SSDP auto-discovery +* Added options flow for configurable scan interval and motion timeout +* Added DataUpdateCoordinator for centralized polling +* Added temperature sensor support +* Added firmware version diagnostic sensor +* Added device info (manufacturer, model, firmware version, hardware version) +* Added diagnostics support with sensitive data redaction +* Added English translations +* Migrated from legacy platform setup to modern integration architecture +* Automatic migration from YAML configuration to config entries +* Bumped minimum Home Assistant version to 2024.4.0 + +*Fixes:* +* Fixed deprecated `DEVICE_CLASS_*` imports causing load failure on HA 2025.1+ ([#18](https://github.com/postlund/dlink_hnap/issues/18)) +* Fixed excessive error logging when device is in power-saving mode or unreachable ([#12](https://github.com/postlund/dlink_hnap/issues/12)) +* Fixed missing device health/availability indication ([#17](https://github.com/postlund/dlink_hnap/issues/17)) +* Fixed compatibility issues with Home Assistant 2025.2.x ([#21](https://github.com/postlund/dlink_hnap/issues/21)) + **Version 0.2.0** * Added support for water leakage sensor (DCH-S160) diff --git a/README.md b/README.md index 8d3db1b..dffad9e 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,111 @@ -![Validate with hassfest](https://github.com/postlund/dlink_hnap/workflows/Validate%20with%20hassfest/badge.svg) -![HACS Validation](https://github.com/postlund/dlink_hnap/workflows/HACS%20Validation/badge.svg) +[![Validate with hassfest](https://github.com/postlund/dlink_hnap/actions/workflows/hassfest.yaml/badge.svg)](https://github.com/postlund/dlink_hnap/actions/workflows/hassfest.yaml) +[![HACS Validation](https://github.com/postlund/dlink_hnap/actions/workflows/validate.yaml/badge.svg)](https://github.com/postlund/dlink_hnap/actions/workflows/validate.yaml) [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) # D-Link HNAP -This is an experimental integration to Home Assistant supporting D-Link devices. It communicates -locally with the devices, i.e. no cloud needed, but only supports polling. +A [Home Assistant](https://www.home-assistant.io/) custom integration for D-Link sensors using the HNAP protocol. -The following devices are supported: +**Key features:** -* Motion Sensor (DCH-S150) -* Water Leakage Sensor (DCH-S160 and DCH-S161) +- **100% local** — communicates directly with devices on your network, no cloud or internet required +- **UI configurable** — full config flow with step-by-step setup from the Home Assistant UI +- **Auto-discovery** — automatically finds D-Link devices on your network via SSDP +- **Automatic capability detection** — probes the device and creates only the entities it supports +- **Configurable options** — adjust polling interval and motion timeout from the UI without restarting +- **Device info** — registers devices with manufacturer, model, firmware version, and hardware version +- **Diagnostics** — built-in diagnostics download with automatic redaction of sensitive data +- **YAML migration** — legacy YAML configurations are automatically imported as config entries -*DISCLAIMER: Currently I don't use any of these devices. So I cannot test the integration. It is -provided as reference and for the community to maintain. Please send PRs!* +## Supported Devices + +| Device | Model | Capabilities | +| ------ | ----- | ------------ | +| Motion Sensor | DCH-S150 | Motion detection | +| Water Leakage Sensor | DCH-S160, DCH-S161 | Water detection, Temperature | + +Other D-Link HNAP-compatible devices may also work. The integration automatically detects which capabilities a device supports by querying its available SOAP actions. + +## Entities + +The integration auto-creates entities based on device capabilities: + +| Entity | Type | Device Class | Description | +| ------ | ---- | ------------ | ----------- | +| Water Detected | Binary Sensor | `moisture` | `on` when water is detected. Only created for devices with water detection capability. | +| Motion Detected | Binary Sensor | `motion` | `on` when motion is detected, turns `off` after the configured motion timeout. Only created for devices with motion detection capability. | +| Temperature | Sensor | `temperature` | Current temperature reading in °C. Only created for devices that report temperature. | +| Firmware Version | Sensor | *(diagnostic)* | Reports the device firmware version. Always created for all devices. | + +All entities are associated with a **device** in the Home Assistant device registry, showing manufacturer (D-Link), model, firmware version, and hardware version. ## Installation ### HACS _(preferred method)_ -This integration is available in the default repository list, just search for it! +1. Open HACS in your Home Assistant instance +2. Search for **D-Link HNAP** in the Integrations section +3. Click **Download** +4. Restart Home Assistant ### Manual install -Place the `custom_components` folder in your configuration directory -(or add its contents to an existing `custom_components` folder). +1. Copy the `custom_components/dlink_hnap` folder into your Home Assistant `config/custom_components/` directory +2. Restart Home Assistant ## Configuration -This integration does not support config flows yet, so you need to add -it in `configuration.yaml`: +### UI Setup (recommended) + +1. In Home Assistant, go to **Settings** → **Devices & Services** +2. Click **+ Add Integration** (bottom right) +3. Search for **D-Link HNAP** +4. Fill in the connection details: + +| Field | Description | +| ----- | ----------- | +| **Host** | IP address or hostname of the D-Link device (e.g. `192.168.1.50`) | +| **Username** | Authentication username — almost always `Admin` (pre-filled) | +| **Password** | The PIN code printed on the device label | + +5. Click **Submit** — the integration will connect to the device, verify credentials, and detect capabilities +6. Entities are created automatically based on what the device supports + +> **Tip:** The PIN code is typically printed on a label on the back or bottom of the device. + +### SSDP Auto-Discovery + +If a D-Link device is on your local network, Home Assistant will automatically discover it via SSDP. When discovered: + +1. A notification appears: *"D-Link device discovered"* +2. Click the notification or go to **Settings** → **Devices & Services** +3. You'll see the discovered device — click **Configure** +4. Enter the **Username** and **PIN code** (password) +5. Click **Submit** to complete setup + +### Options + +After a device is configured, you can adjust settings without restarting: + +1. Go to **Settings** → **Devices & Services** +2. Find the **D-Link HNAP** integration and click **Configure** +3. Adjust the options: + +| Option | Default | Range | Description | +| ------ | ------- | ----- | ----------- | +| **Update interval** | 30 seconds | 5–300 seconds | How frequently the device is polled for new data. Lower values give faster updates but increase network traffic. | +| **Motion timeout** | 30 seconds | 5–600 seconds | How long after the last detected motion before the motion sensor entity turns `off`. Only relevant for motion-capable devices. | + +4. Click **Submit** — changes take effect immediately (the integration reloads automatically) + +## Legacy YAML Configuration *(deprecated)* + +> ⚠️ **YAML configuration is deprecated and will be removed in a future version.** +> Existing YAML configurations are **automatically migrated** to UI config entries on the next restart. +> After migration, you can safely remove the `dlink_hnap` entries from your `configuration.yaml`. + +The legacy YAML format is shown below for reference only: ```yaml binary_sensor: @@ -49,13 +124,76 @@ binary_sensor: password: 123456 ``` -Here are the configuration options: +
+Legacy YAML configuration options + +| Key | Required | Type | Default | Description | +| --- | -------- | ---- | ------- | ----------- | +| `name` | No | string | `D-Link Motion Sensor` | Name for the sensor | +| `type` | **Yes** | string | | Sensor type: `motion` or `water` | +| `host` | **Yes** | string | | IP address of the device | +| `username` | No | string | `Admin` | Username for authentication | +| `password` | **Yes** | string | | PIN code printed on the device | +| `timeout` | No | int | `35` | Seconds before motion sensor turns `off` after last trigger (motion only) | + +
+ +## Diagnostics + +This integration supports Home Assistant's built-in diagnostics feature: + +1. Go to **Settings** → **Devices & Services** → **D-Link HNAP** +2. Click on the device +3. Click the three-dot menu (⋮) → **Download diagnostics** + +The diagnostics file includes: +- Configuration entry data (with password redacted) +- Configured options (scan interval, motion timeout) +- Detected device capabilities +- Current device data (with serial number redacted) + +## Troubleshooting + +| Problem | Solution | +| ------- | -------- | +| **"Failed to connect"** during setup | Verify the IP address is correct and the device is powered on and reachable. Try pinging the device. | +| **"Invalid username or password"** | The password is the numeric PIN code on the device label, not a user-chosen password. Username is almost always `Admin`. | +| **Entities not appearing** | The integration only creates entities for capabilities the device reports. Check diagnostics to see detected capabilities. | +| **Motion sensor stuck on** | Increase the motion timeout in Options. The sensor turns `off` only after no motion is detected for the configured timeout period. | +| **Stale data / slow updates** | Decrease the scan interval in Options (minimum 5 seconds). Note that very low values increase network traffic. | +| **Device becomes unavailable** | The device may have changed IP address. Delete and re-add the integration with the new IP, or assign a static IP/DHCP reservation. | + +## How It Works + +This integration communicates with D-Link devices using the **HNAP** (Home Network Administration Protocol) over SOAP/HTTP. The protocol flow is: + +1. **Authentication** — A challenge-response handshake using HMAC-MD5, establishing a session cookie +2. **Capability detection** — Queries the device's supported SOAP actions to determine which sensors are available +3. **Polling** — Periodically fetches sensor data (water state, motion events, temperature) based on detected capabilities + +All communication happens locally on your network. No data is sent to the cloud. + +## Resolved Issues + +Version 2.0.0 addresses the following open issues: + +| Issue | Description | How it's resolved | +| ----- | ----------- | ----------------- | +| [#18](https://github.com/postlund/dlink_hnap/issues/18) | 2025.1.b5 failed to load sensor | Removed deprecated `DEVICE_CLASS_MOTION` / `DEVICE_CLASS_MOISTURE` imports; fully rewritten with modern `BinarySensorDeviceClass` enums | +| [#21](https://github.com/postlund/dlink_hnap/issues/21) | DLink HNAP issues with HA 2025.2.x | Complete rewrite resolves all compatibility issues with modern Home Assistant | +| [#12](https://github.com/postlund/dlink_hnap/issues/12) | Excessive errors when device is in power-saving mode | `DataUpdateCoordinator` rate-limits error logging; proper `CannotConnect` → `UpdateFailed` handling avoids log spam; configurable scan interval reduces poll frequency | +| [#17](https://github.com/postlund/dlink_hnap/issues/17) | No device health / offline indication | `DataUpdateCoordinator` automatically marks entities as **unavailable** when the device can't be reached — standard HA device health pattern | +| [#20](https://github.com/postlund/dlink_hnap/issues/20) | DCH-S162 not working | Automatic capability detection probes each device's SOAP actions, so any HNAP-compatible device should work. *(Note: The DCH-S162 may use a different protocol — cannot verify without hardware)* | + +Not yet addressed: [#11](https://github.com/postlund/dlink_hnap/issues/11) (Battery attribute) — The SOAP action name for battery state is unknown. Contributions from users with battery-powered devices (DCH-S161) are welcome. + +## Credits + +- **[Pierre Ståhl](https://github.com/postlund)** — Original author of the integration and HNAP protocol implementation +- **[Kyle Cackett](https://github.com/kyle-cackett)** — Bug fixes and compatibility updates +- **[Roger Selwyn](https://github.com/RogerSelwyn)** — Contributions +- **[MichaelB2018](https://github.com/MichaelB2018)** — v2.0.0: Config flow, SSDP discovery, coordinator, options flow, temperature sensor, diagnostics, and modern HA architecture + +## License -key | optional | type | default | description --- | -- | -- | -- | -- -`name` | True | string | D-Link Motion Sensor | Name for the sensor -`type` | False | string | | Sensor type: motion or water -`host` | False | string | | IP address to sensor -`username` | True | string | Admin | Username for authentication (always Admin) -`password` | False | int | | PIN code written on the device -`timeout` | True | int | 35 | Amount of seconds before sensor going to `off` after *last* motion (only used when `type` is `motion`) +This project is licensed under the [MIT License](LICENSE). diff --git a/custom_components/dlink_hnap/__init__.py b/custom_components/dlink_hnap/__init__.py index 9527a64..f5c2b25 100644 --- a/custom_components/dlink_hnap/__init__.py +++ b/custom_components/dlink_hnap/__init__.py @@ -1 +1,72 @@ """D-Link HNAP integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, PLATFORMS +from .coordinator import HNAPDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: + """Set up D-Link HNAP from YAML (import to config entry).""" + hass.data.setdefault(DOMAIN, {}) + + # Check for legacy YAML binary_sensor platform config and import it + if "binary_sensor" in config: + for platform_config in config["binary_sensor"]: + if platform_config.get("platform") != DOMAIN: + continue + + _LOGGER.info( + "Migrating D-Link HNAP YAML configuration to config entry" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "import"}, + data={ + CONF_HOST: platform_config[CONF_HOST], + CONF_USERNAME: platform_config.get(CONF_USERNAME, "Admin"), + CONF_PASSWORD: platform_config[CONF_PASSWORD], + }, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up D-Link HNAP from a config entry.""" + coordinator = HNAPDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a D-Link HNAP config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id, None) + + return unload_ok + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update — reload the config entry.""" + await hass.config_entries.async_reload(entry.entry_id) \ No newline at end of file diff --git a/custom_components/dlink_hnap/binary_sensor.py b/custom_components/dlink_hnap/binary_sensor.py index 627518a..6bf77a1 100644 --- a/custom_components/dlink_hnap/binary_sensor.py +++ b/custom_components/dlink_hnap/binary_sensor.py @@ -1,148 +1,85 @@ -"""Support for D-Link motion sensors.""" -import asyncio -import logging -from datetime import timedelta, datetime +"""Binary sensor platform for D-Link HNAP integration.""" +from __future__ import annotations -import voluptuous as vol +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any from homeassistant.components.binary_sensor import ( - BinarySensorEntity, - PLATFORM_SCHEMA, BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, - CONF_HOST, - CONF_TIMEOUT, - CONF_TYPE, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "D-Link Motion Sensor" -DEFAULT_USERNAME = "Admin" -DEFAULT_TIMEOUT = 35 - -SCAN_INTERVAL = timedelta(seconds=5) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_TYPE): vol.In(["motion", "water"]), - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CAP_MOTION, CAP_WATER, DOMAIN +from .coordinator import HNAPDataUpdateCoordinator +from .entity import HNAPBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class HNAPBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an HNAP binary sensor entity.""" + + value_fn: Callable[[dict[str, Any]], bool | None] + required_capability: str + + +BINARY_SENSOR_DESCRIPTIONS: tuple[HNAPBinarySensorEntityDescription, ...] = ( + HNAPBinarySensorEntityDescription( + key="water", + translation_key="water", + device_class=BinarySensorDeviceClass.MOISTURE, + value_fn=lambda data: data.get("water_detected"), + required_capability=CAP_WATER, + ), + HNAPBinarySensorEntityDescription( + key="motion", + translation_key="motion", + device_class=BinarySensorDeviceClass.MOTION, + value_fn=lambda data: data.get("motion_detected"), + required_capability=CAP_MOTION, + ), ) -async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the D-Link motion sensor.""" - from .dlink import ( - HNAPClient, - MotionSensor, - WaterSensor, - NanoSOAPClient, - ACTION_BASE_URL, - ) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up D-Link HNAP binary sensors from a config entry.""" + coordinator: HNAPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - soap = NanoSOAPClient( - config.get(CONF_HOST), - ACTION_BASE_URL, - loop=hass.loop, - session=async_get_clientsession(hass), - ) + entities = [ + HNAPBinarySensor(coordinator, description) + for description in BINARY_SENSOR_DESCRIPTIONS + if description.required_capability in coordinator.capabilities + ] - client = HNAPClient( - soap, config.get(CONF_USERNAME), config.get(CONF_PASSWORD), loop=hass.loop - ) + async_add_entities(entities) - if config.get(CONF_TYPE) == "motion": - sensor = DlinkMotionSensor( - config.get(CONF_NAME), config.get(CONF_TIMEOUT), MotionSensor(client) - ) - else: - sensor = DlinkWaterSensor(config.get(CONF_NAME), WaterSensor(client)) - async_add_devices([sensor], update_before_add=True) +class HNAPBinarySensor(HNAPBaseEntity, BinarySensorEntity): + """Representation of a D-Link HNAP binary sensor.""" + entity_description: HNAPBinarySensorEntityDescription -class DlinkBinarySensor(BinarySensorEntity): - """Representation of a D-Link binary sensor.""" + def __init__( + self, + coordinator: HNAPDataUpdateCoordinator, + description: HNAPBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description - def __init__(self, name, sensor, device_class): - """Initialize the D-Link motion binary sensor.""" - self._name = name - self._sensor = sensor - self._device_class = device_class - self._on = False + serial = coordinator.data.get("serial", coordinator.entry.entry_id) + self._attr_unique_id = f"{serial}_{description.key}" @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self._on - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - -class DlinkMotionSensor(DlinkBinarySensor): - """Representation of a D-Link motion sensor.""" - - def __init__(self, name, timeout, sensor): - """Initialize the D-Link motion binary sensor.""" - super().__init__(name, sensor, BinarySensorDeviceClass.MOTION) - self._timeout = timeout - - async def async_update(self): - """Get the latest data and updates the states.""" - try: - last_trigger = await self._sensor.latest_trigger() - except Exception: - last_trigger = None - _LOGGER.exception("failed to update motion sensor") - - if not last_trigger: - return - - has_timed_out = datetime.now() > last_trigger + timedelta(seconds=self._timeout) - if has_timed_out: - if self._on: - self._on = False - self.hass.async_add_job(self.async_update_ha_state(True)) - else: - if not self._on: - self._on = True - self.hass.async_add_job(self.async_update_ha_state(True)) - - -class DlinkWaterSensor(DlinkBinarySensor): - """Representation of a D-Link water sensor.""" - - def __init__(self, name, sensor): - """Initialize the D-Link motion binary sensor.""" - super().__init__(name, sensor, BinarySensorDeviceClass.MOISTURE) - - async def async_update(self): - """Get the latest data and updates the states.""" - try: - water_detected = await self._sensor.water_detected() - if self._on != water_detected: - self._on = water_detected - self.hass.async_add_job(self.async_update_ha_state(True)) - - except Exception: - last_trigger = None - _LOGGER.exception("failed to update water sensor") + return self.entity_description.value_fn(self.coordinator.data) diff --git a/custom_components/dlink_hnap/config_flow.py b/custom_components/dlink_hnap/config_flow.py new file mode 100644 index 0000000..0ff0938 --- /dev/null +++ b/custom_components/dlink_hnap/config_flow.py @@ -0,0 +1,218 @@ +"""Config flow for D-Link HNAP integration.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +if TYPE_CHECKING: + from homeassistant.components.ssdp import SsdpServiceInfo + +from .const import ( + DEFAULT_MOTION_TIMEOUT, + DEFAULT_SCAN_INTERVAL, + DEFAULT_USERNAME, + DOMAIN, +) +from .dlink import ( + ACTION_BASE_URL, + AuthenticationError, + CannotConnect, + HNAPClient, + NanoSOAPClient, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_CREDENTIALS_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class DLinkHNAPConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for D-Link HNAP.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + self._discovered_host: str | None = None + self._discovered_name: str | None = None + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> DLinkHNAPOptionsFlow: + """Get the options flow for this handler.""" + return DLinkHNAPOptionsFlow() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle manual setup by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + info = await self._async_validate_input(user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except AuthenticationError: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception during setup") + errors["base"] = "unknown" + else: + unique_id = info["serial"] + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info.get("device_name", "D-Link Sensor"), + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> FlowResult: + """Handle SSDP discovery.""" + _LOGGER.debug("SSDP discovery info: %s", discovery_info) + + # Extract host from SSDP location URL + from urllib.parse import urlparse + + location = discovery_info.ssdp_location or "" + parsed = urlparse(location) + host = parsed.hostname + + if not host: + return self.async_abort(reason="no_host") + + # Use UDN or serial as unique id + upnp = discovery_info.upnp or {} + unique_id = discovery_info.ssdp_usn or upnp.get("serialNumber", "") + if unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + self._discovered_host = host + self._discovered_name = upnp.get("friendlyName", f"D-Link ({host})") + + self.context["title_placeholders"] = {"name": self._discovered_name} + + return await self.async_step_credentials() + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle credentials input after SSDP discovery.""" + errors: dict[str, str] = {} + + if user_input is not None: + full_input = { + CONF_HOST: self._discovered_host, + **user_input, + } + try: + info = await self._async_validate_input(full_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except AuthenticationError: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception during setup") + errors["base"] = "unknown" + else: + # Update unique_id with device serial if we got a better one + if info.get("serial"): + await self.async_set_unique_id(info["serial"]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info.get("device_name", self._discovered_name or "D-Link Sensor"), + data=full_input, + ) + + return self.async_show_form( + step_id="credentials", + data_schema=STEP_CREDENTIALS_SCHEMA, + errors=errors, + description_placeholders={ + "host": self._discovered_host or "", + "name": self._discovered_name or "", + }, + ) + + async def async_step_import( + self, import_data: dict[str, Any] + ) -> FlowResult: + """Handle import from YAML configuration.""" + return await self.async_step_user(import_data) + + async def _async_validate_input( + self, data: dict[str, Any] + ) -> dict[str, Any]: + """Validate user input by connecting to the device.""" + session = async_get_clientsession(self.hass) + soap = NanoSOAPClient(data[CONF_HOST], ACTION_BASE_URL, session=session) + client = HNAPClient(soap, data[CONF_USERNAME], data[CONF_PASSWORD]) + return await client.test_connection() + + +class DLinkHNAPOptionsFlow(OptionsFlow): + """Handle options flow for D-Link HNAP.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + current_interval = self.config_entry.options.get( + "scan_interval", int(DEFAULT_SCAN_INTERVAL.total_seconds()) + ) + current_motion_timeout = self.config_entry.options.get( + "motion_timeout", DEFAULT_MOTION_TIMEOUT + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional("scan_interval", default=current_interval): vol.All( + int, vol.Range(min=5, max=300) + ), + vol.Optional( + "motion_timeout", default=current_motion_timeout + ): vol.All(int, vol.Range(min=5, max=600)), + } + ), + ) diff --git a/custom_components/dlink_hnap/const.py b/custom_components/dlink_hnap/const.py new file mode 100644 index 0000000..3ae5f93 --- /dev/null +++ b/custom_components/dlink_hnap/const.py @@ -0,0 +1,41 @@ +"""Constants for the D-Link HNAP integration.""" +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "dlink_hnap" + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] + +CONF_SERIAL = "serial" + +DEFAULT_USERNAME = "Admin" +DEFAULT_PASSWORD = "" +DEFAULT_TIMEOUT = 30 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) +DEFAULT_MOTION_TIMEOUT = 30 + +# HNAP action names used for capability detection +HNAP_ACTION_WATER = "GetWaterDetectorState" +HNAP_ACTION_MOTION_LATEST = "GetLatestDetection" +HNAP_ACTION_MOTION_LOGS = "GetMotionDetectorLogs" +HNAP_ACTION_TEMPERATURE = "GetCurrentTemperature" +HNAP_ACTION_SYSTEM_LOGS = "GetSystemLogs" + +# Coordinator data keys +KEY_WATER_DETECTED = "water_detected" +KEY_MOTION_DETECTED = "motion_detected" +KEY_LAST_MOTION = "last_motion" +KEY_TEMPERATURE = "temperature" +KEY_BATTERY = "battery" +KEY_FIRMWARE = "firmware" +KEY_MODEL = "model" +KEY_SERIAL = "serial" +KEY_DEVICE_NAME = "device_name" +KEY_HARDWARE_VERSION = "hardware_version" +KEY_AVAILABLE_CAPABILITIES = "available_capabilities" + +# Capability identifiers +CAP_WATER = "water" +CAP_MOTION = "motion" +CAP_TEMPERATURE = "temperature" diff --git a/custom_components/dlink_hnap/coordinator.py b/custom_components/dlink_hnap/coordinator.py new file mode 100644 index 0000000..1c17265 --- /dev/null +++ b/custom_components/dlink_hnap/coordinator.py @@ -0,0 +1,96 @@ +"""DataUpdateCoordinator for D-Link HNAP integration.""" +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +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 .const import ( + DEFAULT_MOTION_TIMEOUT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) +from .dlink import ( + ACTION_BASE_URL, + AuthenticationError, + CannotConnect, + HNAPClient, + NanoSOAPClient, +) + +_LOGGER = logging.getLogger(__name__) + + +class HNAPDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator to manage fetching data from a D-Link HNAP device.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + self.entry = entry + + # Create API client + session = async_get_clientsession(hass) + soap = NanoSOAPClient( + entry.data[CONF_HOST], ACTION_BASE_URL, session=session + ) + self.client = HNAPClient( + soap, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) + + # Capabilities discovered on first update + self.capabilities: set[str] = set() + self._capabilities_detected = False + + # Polling interval from options or default + scan_interval_seconds = entry.options.get( + "scan_interval", int(DEFAULT_SCAN_INTERVAL.total_seconds()) + ) + + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{entry.entry_id}", + update_interval=timedelta(seconds=scan_interval_seconds), + config_entry=entry, + ) + + @property + def motion_timeout(self) -> int: + """Return configured motion timeout in seconds.""" + return self.entry.options.get("motion_timeout", DEFAULT_MOTION_TIMEOUT) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the device.""" + try: + # Detect capabilities on first run + if not self._capabilities_detected: + self.capabilities = await self.client.detect_capabilities() + self._capabilities_detected = True + _LOGGER.info( + "Device capabilities: %s", self.capabilities + ) + + data = await self.client.get_all_data( + capabilities=self.capabilities, + motion_timeout=self.motion_timeout, + ) + + # Include capabilities in data for entity setup + data["available_capabilities"] = self.capabilities + return data + + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + f"Authentication failed: {err}" + ) from err + except CannotConnect as err: + raise UpdateFailed(f"Cannot connect to device: {err}") from err diff --git a/custom_components/dlink_hnap/diagnostics.py b/custom_components/dlink_hnap/diagnostics.py new file mode 100644 index 0000000..4891467 --- /dev/null +++ b/custom_components/dlink_hnap/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for D-Link HNAP integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.redact import async_redact_data + +from .const import DOMAIN +from .coordinator import HNAPDataUpdateCoordinator + +REDACT_KEYS = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: HNAPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + return { + "config_entry": async_redact_data(dict(entry.data), REDACT_KEYS), + "options": dict(entry.options), + "capabilities": sorted(coordinator.capabilities), + "coordinator_data": { + k: v + for k, v in (coordinator.data or {}).items() + if k not in ("serial",) # Redact serial from diagnostics + }, + "device_actions": sorted(coordinator.client.actions or []), + } diff --git a/custom_components/dlink_hnap/dlink.py b/custom_components/dlink_hnap/dlink.py index 2a4a3e5..fddb712 100644 --- a/custom_components/dlink_hnap/dlink.py +++ b/custom_components/dlink_hnap/dlink.py @@ -1,18 +1,18 @@ #!/usr/bin/env python3 -"""Read data from D-Link motion sensor.""" +"""D-Link HNAP protocol client and sensor helpers.""" + +from __future__ import annotations import xml import hmac -import urllib import logging import asyncio -import functools -import aiohttp import xml.etree.ElementTree as ET - from io import BytesIO from datetime import datetime +from typing import Any +import aiohttp import xmltodict _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ ACTION_BASE_URL = "http://purenetworks.com/HNAP1/" -def _hmac(key, message): +def _hmac(key: str, message: str) -> str: return ( hmac.new(key.encode("utf-8"), message.encode("utf-8"), digestmod="MD5") .hexdigest() @@ -31,26 +31,33 @@ def _hmac(key, message): class AuthenticationError(Exception): """Thrown when login fails.""" - pass + +class CannotConnect(Exception): + """Thrown when device is unreachable.""" class HNAPClient: """Client for the HNAP protocol.""" - def __init__(self, soap, username, password, loop=None): + def __init__( + self, + soap: NanoSOAPClient, + username: str, + password: str, + ) -> None: """Initialize a new HNAPClient instance.""" self.username = username self.password = password self.logged_in = False - self.loop = loop or asyncio.get_event_loop() - self.actions = None + self.actions: list[str] | None = None self._client = soap - self._private_key = None - self._cookie = None - self._auth_token = None - self._timestamp = None + self._private_key: str | None = None + self._cookie: str | None = None + self._auth_token: str | None = None + self._timestamp: int | None = None + self._device_settings: dict[str, Any] | None = None - async def login(self): + async def login(self) -> None: """Authenticate with device and obtain cookie.""" _LOGGER.info("Logging into device") self.logged_in = False @@ -89,44 +96,54 @@ async def login(self): raise AuthenticationError("Incorrect username or password") if not self.actions: - self.actions = await self.device_actions() + self.actions = await self._fetch_device_actions() - except xml.parsers.expat.ExpatError: - raise AuthenticationError("Bad response from device") + except xml.parsers.expat.ExpatError as err: + raise AuthenticationError("Bad response from device") from err self.logged_in = True - async def device_actions(self): - actions = await self.call("GetDeviceSettings") + async def _fetch_device_actions(self) -> list[str]: + """Fetch supported SOAP actions from device.""" + settings = await self._get_device_settings_raw() return list( - map(lambda x: x[x.rfind("/") + 1 :], actions["SOAPActions"]["string"]) + map(lambda x: x[x.rfind("/") + 1 :], settings["SOAPActions"]["string"]) ) - async def soap_actions(self, module_id): + async def _get_device_settings_raw(self) -> dict[str, Any]: + """Fetch and cache raw GetDeviceSettings response.""" + if self._device_settings is None: + self._device_settings = await self.call("GetDeviceSettings") + return self._device_settings + + async def soap_actions(self, module_id: int) -> dict[str, Any]: + """Get SOAP actions for a module.""" return await self.call("GetModuleSOAPActions", ModuleID=module_id) - async def call(self, method, *args, **kwargs): - """Call an NHAP method (async).""" - # Do login if no login has been done before + async def call(self, method: str, **kwargs: Any) -> dict[str, Any]: + """Call an HNAP method (async).""" if not self._private_key and method != "Login": await self.login() self._update_nauth_token(method) try: - result = await self.soap().call(method, **kwargs) + result = await self._get_soap_client().call(method, **kwargs) if "ERROR" in result: self._bad_response() - except: + except (AuthenticationError, CannotConnect): + raise + except Exception: self._bad_response() return result - def _bad_response(self): + def _bad_response(self) -> None: _LOGGER.error("Got an error, resetting private key") self._private_key = None - raise Exception("got error response from device") + self._device_settings = None + raise CannotConnect("Got error response from device") - def _update_nauth_token(self, action): - """Update NHAP auth token for an action.""" + def _update_nauth_token(self, action: str) -> None: + """Update HNAP auth token for an action.""" if not self._private_key: return @@ -142,7 +159,7 @@ def _update_nauth_token(self, action): self._timestamp, ) - def soap(self): + def _get_soap_client(self) -> NanoSOAPClient: """Get SOAP client with updated headers.""" if self._cookie: self._client.headers["Cookie"] = "uid={0}".format(self._cookie) @@ -150,66 +167,157 @@ def soap(self): self._client.headers["HNAP_AUTH"] = "{0} {1}".format( self._auth_token, self._timestamp ) - return self._client + # ── High-level API methods for the coordinator ────────────────────── + + async def test_connection(self) -> dict[str, Any]: + """Validate connection and credentials. Returns device info on success. + + Raises AuthenticationError or CannotConnect on failure. + """ + await self.login() + return await self.get_device_info() + + async def get_device_info(self) -> dict[str, Any]: + """Return device metadata (model, firmware, serial, name).""" + settings = await self._get_device_settings_raw() + return { + "model": settings.get("ModelName", "Unknown"), + "device_name": settings.get("DeviceName", "D-Link Sensor"), + "firmware": settings.get("FirmwareVersion", "Unknown"), + "hardware_version": settings.get("HardwareVersion", ""), + "serial": settings.get("DeviceMacId", settings.get("MacAddress", "")), + } + + async def get_module_soap_actions(self, module_id: int = 1) -> list[str]: + """Return list of SOAP action names for a module.""" + resp = await self.soap_actions(module_id) + actions = resp.get("ModuleSOAPList", {}).get("SOAPActions", {}).get("Action", []) + if isinstance(actions, str): + actions = [actions] + return actions + + async def get_water_state(self, module_id: int = 1) -> bool: + """Return True if water is detected.""" + resp = await self.call("GetWaterDetectorState", ModuleID=module_id) + return resp.get("IsWater") == "true" -class BaseSensor: - """Wrapper class for a sensor.""" - - def __init__(self, client, module_id=1): - """Initialize a new BaseSensor instance.""" - self.client = client - self.module_id = module_id - self._soap_actions = None - - async def latest_trigger(self): - """Get latest trigger time from sensor.""" - if not self._soap_actions: - await self._cache_soap_actions() + async def get_latest_motion(self, module_id: int = 1) -> datetime | None: + """Return timestamp of latest motion event, or None.""" + module_actions = await self.get_module_soap_actions(module_id) detect_time = None - if "GetLatestDetection" in self._soap_actions: - resp = await self.client.call("GetLatestDetection", ModuleID=self.module_id) - detect_time = resp["LatestDetectTime"] + if "GetLatestDetection" in module_actions: + resp = await self.call("GetLatestDetection", ModuleID=module_id) + detect_time = resp.get("LatestDetectTime") else: - resp = await self.client.call( + resp = await self.call( "GetMotionDetectorLogs", - ModuleID=self.module_id, + ModuleID=module_id, MaxCount=1, PageOffset=1, StartTime=0, EndTime="All", ) - if "MotionDetectorLogList" not in resp: - _LOGGER.error("log list: " + str(resp)) - log_list = resp["MotionDetectorLogList"] - detect_time = log_list["MotionDetectorLog"]["TimeStamp"] + log_list = resp.get("MotionDetectorLogList", {}) + log_entry = log_list.get("MotionDetectorLog", {}) + detect_time = log_entry.get("TimeStamp") + + if detect_time is not None: + try: + return datetime.fromtimestamp(float(detect_time)) + except (ValueError, TypeError, OSError): + _LOGGER.warning("Invalid motion detect time: %s", detect_time) + return None + + async def get_temperature(self, module_id: int = 1) -> float | None: + """Return temperature in Celsius, or None if unavailable.""" + try: + resp = await self.call("GetCurrentTemperature", ModuleID=module_id) + value = resp.get("CurrentTemperature") + if value is not None: + return float(value) + except Exception: + _LOGGER.debug("Temperature not available from device") + return None + + async def get_all_data( + self, + capabilities: set[str], + module_id: int = 1, + motion_timeout: int = 30, + ) -> dict[str, Any]: + """Fetch all sensor data in one coordinated call. + + Only fetches data for the given capabilities to avoid unnecessary calls. + """ + data: dict[str, Any] = {} + + if "water" in capabilities: + try: + data["water_detected"] = await self.get_water_state(module_id) + except Exception: + _LOGGER.debug("Failed to get water state", exc_info=True) + data["water_detected"] = None + + if "motion" in capabilities: + try: + last_motion = await self.get_latest_motion(module_id) + data["last_motion"] = last_motion + if last_motion is not None: + elapsed = (datetime.now() - last_motion).total_seconds() + data["motion_detected"] = elapsed <= motion_timeout + else: + data["motion_detected"] = False + except Exception: + _LOGGER.debug("Failed to get motion state", exc_info=True) + data["motion_detected"] = None + data["last_motion"] = None + + if "temperature" in capabilities: + data["temperature"] = await self.get_temperature(module_id) + + # Device info is always fetched + try: + info = await self.get_device_info() + data.update(info) + except Exception: + _LOGGER.debug("Failed to refresh device info", exc_info=True) - return datetime.fromtimestamp(float(detect_time)) + return data - async def _cache_soap_actions(self): - resp = await self.client.soap_actions(self.module_id) - self._soap_actions = resp["ModuleSOAPList"]["SOAPActions"]["Action"] + async def detect_capabilities(self, module_id: int = 1) -> set[str]: + """Probe device to discover available capabilities.""" + capabilities: set[str] = set() + # Get device-level actions + if not self.actions: + await self.login() -class MotionSensor(BaseSensor): - """Wrapper class for motion sensor.""" + device_actions = self.actions or [] + # Get module-level actions + try: + module_actions = await self.get_module_soap_actions(module_id) + except Exception: + module_actions = [] -class WaterSensor(BaseSensor): - """Wrapper class for water detect sensor.""" + all_actions = set(device_actions) | set(module_actions) - async def water_detected(self): - """Get latest trigger time from sensor.""" - if not self._soap_actions: - await self._cache_soap_actions() + if "GetWaterDetectorState" in all_actions: + capabilities.add("water") + if "GetLatestDetection" in all_actions or "GetMotionDetectorLogs" in all_actions: + capabilities.add("motion") + if "GetCurrentTemperature" in all_actions: + capabilities.add("temperature") - resp = await self.client.call("GetWaterDetectorState", ModuleID=self.module_id) - return resp.get("IsWater") == "true" + _LOGGER.info("Detected capabilities: %s (from %d actions)", capabilities, len(all_actions)) + return capabilities class NanoSOAPClient: + """Lightweight async SOAP client for HNAP.""" BASE_NS = { "xmlns:soap": "http://schemas.xmlsoap.org/soap/envelope/", @@ -218,14 +326,13 @@ class NanoSOAPClient: } ACTION_NS = {"xmlns": "http://purenetworks.com/HNAP1/"} - def __init__(self, address, action, loop=None, session=None): - self.address = "http://{0}/HNAP1".format(address) + def __init__(self, address: str, action: str, session: aiohttp.ClientSession) -> None: + self.address = f"http://{address}/HNAP1" self.action = action - self.loop = loop or asyncio.get_event_loop() - self.session = session or aiohttp.ClientSession(loop=loop) - self.headers = {} + self.session = session + self.headers: dict[str, str] = {} - def _generate_request_xml(self, method, **kwargs): + def _generate_request_xml(self, method: str, **kwargs: Any) -> str: body = ET.Element("soap:Body") action = ET.Element(method, self.ACTION_NS) body.append(action) @@ -244,27 +351,36 @@ def _generate_request_xml(self, method, **kwargs): return f.getvalue().decode("utf-8") - async def call(self, method, **kwargs): - xml = self._generate_request_xml(method, **kwargs) + async def call(self, method: str, **kwargs: Any) -> dict[str, Any]: + """Make an HNAP SOAP call.""" + request_xml = self._generate_request_xml(method, **kwargs) headers = self.headers.copy() headers["SOAPAction"] = '"{0}{1}"'.format(self.action, method) - resp = await self.session.post( - self.address, data=xml, headers=headers, timeout=10 - ) - text = await resp.text() - parsed = xmltodict.parse(text) + try: + async with asyncio.timeout(10): + resp = await self.session.post( + self.address, data=request_xml, headers=headers, + ) + text = await resp.text() + except (aiohttp.ClientError, TimeoutError, OSError) as err: + raise CannotConnect(f"Cannot reach device at {self.address}") from err + + try: + parsed = xmltodict.parse(text) + except Exception as err: + raise CannotConnect(f"Invalid XML response from device") from err + if "soap:Envelope" not in parsed: - _LOGGER.error("parsed: " + str(parsed)) - raise Exception("probably a bad response") + _LOGGER.error("Unexpected response: %s", str(parsed)[:200]) + raise CannotConnect("Unexpected response from device") return parsed["soap:Envelope"]["soap:Body"][method + "Response"] if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - loop = asyncio.get_event_loop() import sys @@ -272,27 +388,33 @@ async def call(self, method, **kwargs): pin = sys.argv[2] cmd = sys.argv[3] - async def _print_latest_motion(): + async def _main() -> None: session = aiohttp.ClientSession() - soap = NanoSOAPClient(address, ACTION_BASE_URL, loop=loop, session=session) - client = HNAPClient(soap, "Admin", pin, loop=loop) - await client.login() - - if cmd == "latest_motion": - latest = await BaseSensor(client).latest_trigger() - print("Latest time: " + str(latest)) - elif cmd == "water_detected": - latest = await WaterSensor(client).water_detected() - print("Water detected: " + str(latest)) - elif cmd == "actions": - print("Supported actions:") - print("\n".join(client.actions)) - elif cmd == "log": - resp = await self.client.call( - "GetSystemLogs", MaxCount=100, PageOffset=1, StartTime=0, EndTime="All" - ) - print(resp) - - session.close() - - loop.run_until_complete(_print_latest_motion()) + try: + soap = NanoSOAPClient(address, ACTION_BASE_URL, session=session) + client = HNAPClient(soap, "Admin", pin) + await client.login() + + if cmd == "latest_motion": + latest = await client.get_latest_motion() + print("Latest time: " + str(latest)) + elif cmd == "water_detected": + detected = await client.get_water_state() + print("Water detected: " + str(detected)) + elif cmd == "actions": + print("Supported actions:") + print("\n".join(client.actions or [])) + elif cmd == "capabilities": + caps = await client.detect_capabilities() + print("Capabilities: " + str(caps)) + elif cmd == "info": + info = await client.get_device_info() + for k, v in info.items(): + print(f" {k}: {v}") + elif cmd == "temperature": + temp = await client.get_temperature() + print("Temperature: " + str(temp)) + finally: + await session.close() + + asyncio.run(_main()) diff --git a/custom_components/dlink_hnap/entity.py b/custom_components/dlink_hnap/entity.py new file mode 100644 index 0000000..87909cf --- /dev/null +++ b/custom_components/dlink_hnap/entity.py @@ -0,0 +1,33 @@ +"""Base entity for D-Link HNAP integration.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HNAPDataUpdateCoordinator + + +class HNAPBaseEntity(CoordinatorEntity[HNAPDataUpdateCoordinator]): + """Base class for D-Link HNAP entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HNAPDataUpdateCoordinator) -> None: + """Initialize the base entity.""" + super().__init__(coordinator) + + serial = coordinator.data.get("serial", coordinator.entry.entry_id) + model = coordinator.data.get("model", "Unknown") + device_name = coordinator.data.get("device_name", "D-Link Sensor") + firmware = coordinator.data.get("firmware", "Unknown") + hw_version = coordinator.data.get("hardware_version") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial)}, + name=device_name, + manufacturer="D-Link", + model=model, + sw_version=firmware, + hw_version=hw_version if hw_version else None, + ) diff --git a/custom_components/dlink_hnap/manifest.json b/custom_components/dlink_hnap/manifest.json index 493dd1b..67ea07d 100644 --- a/custom_components/dlink_hnap/manifest.json +++ b/custom_components/dlink_hnap/manifest.json @@ -1,11 +1,20 @@ { "domain": "dlink_hnap", "name": "D-Link HNAP", - "config_flow": false, + "config_flow": true, "documentation": "https://github.com/postlund/dlink_hnap", "codeowners": [ "@postlund" ], "issue_tracker": "https://github.com/postlund/dlink_hnap/issues", - "version": "1.0.0" + "version": "2.0.0", + "iot_class": "local_polling", + "requirements": [ + "xmltodict>=0.13.0" + ], + "ssdp": [ + { + "manufacturer": "D-Link" + } + ] } diff --git a/custom_components/dlink_hnap/sensor.py b/custom_components/dlink_hnap/sensor.py new file mode 100644 index 0000000..01191b1 --- /dev/null +++ b/custom_components/dlink_hnap/sensor.py @@ -0,0 +1,91 @@ +"""Sensor platform for D-Link HNAP integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CAP_TEMPERATURE, DOMAIN +from .coordinator import HNAPDataUpdateCoordinator +from .entity import HNAPBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class HNAPSensorEntityDescription(SensorEntityDescription): + """Describes an HNAP sensor entity.""" + + value_fn: Callable[[dict[str, Any]], float | str | None] + required_capability: str | None = None + + +SENSOR_DESCRIPTIONS: tuple[HNAPSensorEntityDescription, ...] = ( + HNAPSensorEntityDescription( + key="temperature", + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: data.get("temperature"), + required_capability=CAP_TEMPERATURE, + ), + HNAPSensorEntityDescription( + key="firmware", + translation_key="firmware", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:package-variant", + value_fn=lambda data: data.get("firmware"), + required_capability=None, # Always available + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up D-Link HNAP sensors from a config entry.""" + coordinator: HNAPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + HNAPSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + if description.required_capability is None + or description.required_capability in coordinator.capabilities + ] + + async_add_entities(entities) + + +class HNAPSensor(HNAPBaseEntity, SensorEntity): + """Representation of a D-Link HNAP sensor.""" + + entity_description: HNAPSensorEntityDescription + + def __init__( + self, + coordinator: HNAPDataUpdateCoordinator, + description: HNAPSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + + serial = coordinator.data.get("serial", coordinator.entry.entry_id) + self._attr_unique_id = f"{serial}_{description.key}" + + @property + def native_value(self) -> float | str | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/custom_components/dlink_hnap/strings.json b/custom_components/dlink_hnap/strings.json new file mode 100644 index 0000000..0c5dcfd --- /dev/null +++ b/custom_components/dlink_hnap/strings.json @@ -0,0 +1,61 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to D-Link Device", + "description": "Enter the connection details for your D-Link HNAP device.", + "data": { + "host": "Host", + "username": "Username", + "password": "Password" + } + }, + "credentials": { + "title": "Authenticate with {name}", + "description": "A D-Link device was discovered at {host}. Enter the credentials to connect.", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the device. Check the host address and ensure the device is powered on.", + "invalid_auth": "Invalid username or password.", + "unknown": "An unexpected error occurred." + }, + "abort": { + "already_configured": "This device is already configured.", + "no_host": "Could not determine host from discovery information." + } + }, + "options": { + "step": { + "init": { + "title": "D-Link HNAP Options", + "data": { + "scan_interval": "Update interval (seconds)", + "motion_timeout": "Motion timeout (seconds)" + } + } + } + }, + "entity": { + "binary_sensor": { + "water": { + "name": "Water detected" + }, + "motion": { + "name": "Motion detected" + } + }, + "sensor": { + "temperature": { + "name": "Temperature" + }, + "firmware": { + "name": "Firmware version" + } + } + } +} diff --git a/custom_components/dlink_hnap/translations/en.json b/custom_components/dlink_hnap/translations/en.json new file mode 100644 index 0000000..0c5dcfd --- /dev/null +++ b/custom_components/dlink_hnap/translations/en.json @@ -0,0 +1,61 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to D-Link Device", + "description": "Enter the connection details for your D-Link HNAP device.", + "data": { + "host": "Host", + "username": "Username", + "password": "Password" + } + }, + "credentials": { + "title": "Authenticate with {name}", + "description": "A D-Link device was discovered at {host}. Enter the credentials to connect.", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the device. Check the host address and ensure the device is powered on.", + "invalid_auth": "Invalid username or password.", + "unknown": "An unexpected error occurred." + }, + "abort": { + "already_configured": "This device is already configured.", + "no_host": "Could not determine host from discovery information." + } + }, + "options": { + "step": { + "init": { + "title": "D-Link HNAP Options", + "data": { + "scan_interval": "Update interval (seconds)", + "motion_timeout": "Motion timeout (seconds)" + } + } + } + }, + "entity": { + "binary_sensor": { + "water": { + "name": "Water detected" + }, + "motion": { + "name": "Motion detected" + } + }, + "sensor": { + "temperature": { + "name": "Temperature" + }, + "firmware": { + "name": "Firmware version" + } + } + } +} diff --git a/hacs.json b/hacs.json index bc7320c..5a8d260 100644 --- a/hacs.json +++ b/hacs.json @@ -1,10 +1,5 @@ { "name": "D-Link HNAP", - "content_in_root": false, - "domains": [ - "binary_sensor" - ], - "homeassistant": "0.109.0", - "iot_class": "Local Pull", - "render_readme": true + "render_readme": true, + "homeassistant": "2024.4.0" } From 5cbe526a6af607672fcee4090e4383e62ce36ddd Mon Sep 17 00:00:00 2001 From: MichaelB2018 Date: Wed, 1 Apr 2026 21:51:41 -0400 Subject: [PATCH 2/2] chore: prepare maintained fork release metadata --- CHANGELOG.md | 8 +++--- README.md | 30 ++++++++++++---------- custom_components/dlink_hnap/manifest.json | 6 ++--- pyproject.toml | 2 +- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 069fba7..4d68bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,10 @@ * Bumped minimum Home Assistant version to 2024.4.0 *Fixes:* -* Fixed deprecated `DEVICE_CLASS_*` imports causing load failure on HA 2025.1+ ([#18](https://github.com/postlund/dlink_hnap/issues/18)) -* Fixed excessive error logging when device is in power-saving mode or unreachable ([#12](https://github.com/postlund/dlink_hnap/issues/12)) -* Fixed missing device health/availability indication ([#17](https://github.com/postlund/dlink_hnap/issues/17)) -* Fixed compatibility issues with Home Assistant 2025.2.x ([#21](https://github.com/postlund/dlink_hnap/issues/21)) +* Fixed deprecated `DEVICE_CLASS_*` imports causing load failure on HA 2025.1+ ([Upstream #18](https://github.com/postlund/dlink_hnap/issues/18)) +* Fixed excessive error logging when device is in power-saving mode or unreachable ([Upstream #12](https://github.com/postlund/dlink_hnap/issues/12)) +* Fixed missing device health/availability indication ([Upstream #17](https://github.com/postlund/dlink_hnap/issues/17)) +* Fixed compatibility issues with Home Assistant 2025.2.x ([Upstream #21](https://github.com/postlund/dlink_hnap/issues/21)) **Version 0.2.0** diff --git a/README.md b/README.md index dffad9e..9e30859 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ -[![Validate with hassfest](https://github.com/postlund/dlink_hnap/actions/workflows/hassfest.yaml/badge.svg)](https://github.com/postlund/dlink_hnap/actions/workflows/hassfest.yaml) -[![HACS Validation](https://github.com/postlund/dlink_hnap/actions/workflows/validate.yaml/badge.svg)](https://github.com/postlund/dlink_hnap/actions/workflows/validate.yaml) -[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) +[![Validate with hassfest](https://github.com/MichaelB2018/dlink_hnap/actions/workflows/hassfest.yaml/badge.svg)](https://github.com/MichaelB2018/dlink_hnap/actions/workflows/hassfest.yaml) +[![HACS Validation](https://github.com/MichaelB2018/dlink_hnap/actions/workflows/validate.yaml/badge.svg)](https://github.com/MichaelB2018/dlink_hnap/actions/workflows/validate.yaml) +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) # D-Link HNAP A [Home Assistant](https://www.home-assistant.io/) custom integration for D-Link sensors using the HNAP protocol. +This repository is a community-maintained fork of the original integration. Upstream improvements have not been merged, so releases are published here for users who want the maintained version. + **Key features:** - **100% local** — communicates directly with devices on your network, no cloud or internet required @@ -45,9 +47,11 @@ All entities are associated with a **device** in the Home Assistant device regis ### HACS _(preferred method)_ 1. Open HACS in your Home Assistant instance -2. Search for **D-Link HNAP** in the Integrations section -3. Click **Download** -4. Restart Home Assistant +2. Go to **Integrations** and open the three-dot menu +3. Select **Custom repositories** +4. Add `https://github.com/MichaelB2018/dlink_hnap` as category **Integration** +5. Search for **D-Link HNAP** in HACS and click **Download** +6. Restart Home Assistant ### Manual install @@ -179,20 +183,20 @@ Version 2.0.0 addresses the following open issues: | Issue | Description | How it's resolved | | ----- | ----------- | ----------------- | -| [#18](https://github.com/postlund/dlink_hnap/issues/18) | 2025.1.b5 failed to load sensor | Removed deprecated `DEVICE_CLASS_MOTION` / `DEVICE_CLASS_MOISTURE` imports; fully rewritten with modern `BinarySensorDeviceClass` enums | -| [#21](https://github.com/postlund/dlink_hnap/issues/21) | DLink HNAP issues with HA 2025.2.x | Complete rewrite resolves all compatibility issues with modern Home Assistant | -| [#12](https://github.com/postlund/dlink_hnap/issues/12) | Excessive errors when device is in power-saving mode | `DataUpdateCoordinator` rate-limits error logging; proper `CannotConnect` → `UpdateFailed` handling avoids log spam; configurable scan interval reduces poll frequency | -| [#17](https://github.com/postlund/dlink_hnap/issues/17) | No device health / offline indication | `DataUpdateCoordinator` automatically marks entities as **unavailable** when the device can't be reached — standard HA device health pattern | -| [#20](https://github.com/postlund/dlink_hnap/issues/20) | DCH-S162 not working | Automatic capability detection probes each device's SOAP actions, so any HNAP-compatible device should work. *(Note: The DCH-S162 may use a different protocol — cannot verify without hardware)* | +| [Upstream #18](https://github.com/postlund/dlink_hnap/issues/18) | 2025.1.b5 failed to load sensor | Removed deprecated `DEVICE_CLASS_MOTION` / `DEVICE_CLASS_MOISTURE` imports; fully rewritten with modern `BinarySensorDeviceClass` enums | +| [Upstream #21](https://github.com/postlund/dlink_hnap/issues/21) | DLink HNAP issues with HA 2025.2.x | Complete rewrite resolves all compatibility issues with modern Home Assistant | +| [Upstream #12](https://github.com/postlund/dlink_hnap/issues/12) | Excessive errors when device is in power-saving mode | `DataUpdateCoordinator` rate-limits error logging; proper `CannotConnect` → `UpdateFailed` handling avoids log spam; configurable scan interval reduces poll frequency | +| [Upstream #17](https://github.com/postlund/dlink_hnap/issues/17) | No device health / offline indication | `DataUpdateCoordinator` automatically marks entities as **unavailable** when the device can't be reached — standard HA device health pattern | +| [Upstream #20](https://github.com/postlund/dlink_hnap/issues/20) | DCH-S162 not working | Automatic capability detection probes each device's SOAP actions, so any HNAP-compatible device should work. *(Note: The DCH-S162 may use a different protocol — cannot verify without hardware)* | -Not yet addressed: [#11](https://github.com/postlund/dlink_hnap/issues/11) (Battery attribute) — The SOAP action name for battery state is unknown. Contributions from users with battery-powered devices (DCH-S161) are welcome. +Not yet addressed: [Upstream #11](https://github.com/postlund/dlink_hnap/issues/11) (Battery attribute) — The SOAP action name for battery state is unknown. Contributions from users with battery-powered devices (DCH-S161) are welcome. ## Credits - **[Pierre Ståhl](https://github.com/postlund)** — Original author of the integration and HNAP protocol implementation - **[Kyle Cackett](https://github.com/kyle-cackett)** — Bug fixes and compatibility updates - **[Roger Selwyn](https://github.com/RogerSelwyn)** — Contributions -- **[MichaelB2018](https://github.com/MichaelB2018)** — v2.0.0: Config flow, SSDP discovery, coordinator, options flow, temperature sensor, diagnostics, and modern HA architecture +- **[MichaelB2018](https://github.com/MichaelB2018)** — Maintained fork releases, v2.0.0 architecture update, config flow, SSDP discovery, coordinator, options flow, temperature sensor, diagnostics, and modern HA architecture ## License diff --git a/custom_components/dlink_hnap/manifest.json b/custom_components/dlink_hnap/manifest.json index 67ea07d..91d0fcb 100644 --- a/custom_components/dlink_hnap/manifest.json +++ b/custom_components/dlink_hnap/manifest.json @@ -2,11 +2,11 @@ "domain": "dlink_hnap", "name": "D-Link HNAP", "config_flow": true, - "documentation": "https://github.com/postlund/dlink_hnap", + "documentation": "https://github.com/MichaelB2018/dlink_hnap", "codeowners": [ - "@postlund" + "@MichaelB2018" ], - "issue_tracker": "https://github.com/postlund/dlink_hnap/issues", + "issue_tracker": "https://github.com/MichaelB2018/dlink_hnap/issues", "version": "2.0.0", "iot_class": "local_polling", "requirements": [ diff --git a/pyproject.toml b/pyproject.toml index 0852bbf..0495ddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dlink_hnap" -version = "0.2.0" +version = "2.0.0" description = "Home Assistant custom integration for D-Link sensors" authors = ["Pierre Ståhl "] license = "MIT"