diff --git a/CHANGELOG.md b/CHANGELOG.md
index d151617..4d68bca 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+ ([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**
* Added support for water leakage sensor (DCH-S160)
diff --git a/README.md b/README.md
index 8d3db1b..9e30859 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,115 @@
-
-
-[](https://github.com/custom-components/hacs)
+[](https://github.com/MichaelB2018/dlink_hnap/actions/workflows/hassfest.yaml)
+[](https://github.com/MichaelB2018/dlink_hnap/actions/workflows/validate.yaml)
+[](https://github.com/custom-components/hacs)
[](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:
+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.
-* Motion Sensor (DCH-S150)
-* Water Leakage Sensor (DCH-S160 and DCH-S161)
+**Key features:**
-*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!*
+- **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
+
+## 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. 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
-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 +128,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 |
+| ----- | ----------- | ----------------- |
+| [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: [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)** — Maintained fork releases, v2.0.0 architecture update, 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..91d0fcb 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,
- "documentation": "https://github.com/postlund/dlink_hnap",
+ "config_flow": true,
+ "documentation": "https://github.com/MichaelB2018/dlink_hnap",
"codeowners": [
- "@postlund"
+ "@MichaelB2018"
],
- "issue_tracker": "https://github.com/postlund/dlink_hnap/issues",
- "version": "1.0.0"
+ "issue_tracker": "https://github.com/MichaelB2018/dlink_hnap/issues",
+ "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"
}
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"