Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,20 @@ jobs:
run: ruff check .
- name: Test
run: pytest -q

hassfest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: home-assistant/actions/hassfest@master

hacs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hacs/action@main
with:
category: integration
# GitHub only detects a repository license from the default branch.
# Keep feature branches testable until their LICENSE reaches main.
ignore: ${{ github.ref_name != github.event.repository.default_branch && 'license' || '' }}
57 changes: 57 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Verify tag matches manifest version
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
python3 - <<'PY'
import json
import os
from pathlib import Path

tag_version = os.environ["RELEASE_TAG"].removeprefix("v")
manifest = json.loads(
Path("custom_components/duepi/manifest.json").read_text()
)
manifest_version = manifest["version"]
if tag_version != manifest_version:
raise SystemExit(
f"Tag version {tag_version} does not match manifest {manifest_version}"
)
PY

- name: Build HACS release archive
working-directory: custom_components/duepi
run: |
zip -r "$GITHUB_WORKSPACE/duepi.zip" . \
-x "__pycache__/*" "__pycache__/" \
"*/__pycache__/*" "*/__pycache__/" "*.pyc"

- name: Verify archive layout
run: |
unzip -Z1 duepi.zip | grep -qx "manifest.json"
if unzip -Z1 duepi.zip | grep -q '^custom_components/'; then
echo "Archive must contain integration files at its root" >&2
exit 1
fi

- name: Publish GitHub release
uses: softprops/action-gh-release@v3
with:
files: duepi.zip
fail_on_unmatched_files: true
generate_release_notes: true
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Florian Casse

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
55 changes: 28 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
# DuepiRemoteHA

<p align="center">
<img src="custom_components/duepi/brand/icon.png" alt="Duepi pellet stove icon" width="160">
</p>

Control a Duepi pellet stove from Home Assistant via the [dpremoteiot.com](https://dpremoteiot.com) cloud API.

## Features

- **Native Climate entity** — full HVAC control with temperature and power level, using the stove's built-in temperature sensor
- **Auto-login** — authenticates with email/password, auto-renews the session when it expires (no more manual cookie extraction!)
- **Automatic stove discovery** — sign in and select a stove without hunting for its device ID
- **Config flow UI** — set up entirely from the Home Assistant UI (Settings → Add Integration)
- **Rich entities** — climate, temperature sensor, power level, status text, online/offline connectivity, power slider
- **Eco and Comfort presets** — switch temperature and power together from the climate entity
- **Rich diagnostics** — alarms, problem state, last-seen time, operating hours, and estimated pellet use
- **HACS compatible** — install easily via HACS custom repository
- **Reauth flow** — automatically prompts for new credentials if authentication fails
- **Diagnostics** — built-in diagnostic dump for troubleshooting
Expand All @@ -19,8 +25,6 @@ Control a Duepi pellet stove from Home Assistant via the [dpremoteiot.com](https
- An account on [dpremoteiot.com](https://dpremoteiot.com) with your stove added.
- Home Assistant 2024.6.0 or later.

![Account and Device ID](screenshots/AccountAndDeviceID.png)

## Installation

### Via HACS (recommended)
Expand Down Expand Up @@ -62,57 +66,53 @@ Use this method if you don't want to use HACS.

Once the integration is installed and HA has restarted:

### Step 1 — Find your Device ID

Before adding the integration, you need your **Device ID** from dpremoteiot.com:

1. Log in to [dpremoteiot.com](https://dpremoteiot.com).
2. On the dashboard, find your stove and look for the **delete device** button.
3. Inspect the button's link — it contains `deviceid=YOUR_DEVICE_ID`.

![Account and Device ID](screenshots/AccountAndDeviceID.png)

### Step 2 — Add the integration
### Step 1 — Add the integration

1. Go to **Settings → Devices & Services**.
2. Click **+ Add Integration** (bottom-right).
3. Search for **Duepi Pellet Stove** and select it.
4. Fill in the form:

| Field | Description |
|---|---|
| **Email** | Your dpremoteiot.com account email |
| **Password** | Your dpremoteiot.com account password |
| **Device ID** | The device ID you found in Step 1 |
4. Enter your dpremoteiot.com email and password.
5. Select the stove discovered on your account. Selection is shown even when the account contains only one stove.
6. Click **Submit**. A new **Duepi Pellet Stove** device appears with all its entities.

5. Click **Submit**. The integration will validate your credentials by logging in to dpremoteiot.com.
6. If successful, a new **Duepi Pellet Stove** device appears with all its entities.
If the dashboard format prevents automatic discovery, the flow falls back to a manual **Device ID** field. Existing installations keep their current device ID and do not need to be reconfigured.

> **Authentication failed?** Double-check your email and password. Make sure you can log in to [dpremoteiot.com](https://dpremoteiot.com) with the same credentials in a browser.

### Step 3 — Configure options (optional)
### Step 2 — Configure options (optional)

1. Go to **Settings → Devices & Services → Duepi Pellet Stove**.
2. Click **Configure**.

| Option | Default | Description |
|---|---|---|
| Update interval | 30s | How often to poll the stove state (min: 30s) |
| Default power | 5 | Power level used when turning on (1-5) |
| Default temperature | 25°C | Temperature used when turning on (5-40°C) |
| Comfort/default power | 5 | Power level used for Comfort and normal turn-on (1-5) |
| Comfort/default temperature | 25°C | Temperature used for Comfort and normal turn-on (5-40°C) |
| Eco power | 1 | Power level used by the Eco preset (1-5) |
| Eco temperature | 18°C | Temperature used by the Eco preset (5-40°C) |
| Pellet rate at power 1 | 0.6 kg/h | Estimated minimum consumption rate (0-10 kg/h) |
| Pellet rate at power 5 | 1.8 kg/h | Estimated maximum consumption rate (0-10 kg/h) |

The minimum pellet rate must not exceed the maximum. Changing any option reloads the integration automatically.

## Entities

All entities are grouped under a single **Duepi Pellet Stove** device:

| Entity | Type | Description |
|---|---|---|
| Duepi Pellet Stove | Climate | Main control — HVAC mode (heat/off), target temperature, fan mode (power 1-5) |
| Duepi Pellet Stove | Climate | Main control — HVAC mode, target temperature, power 1-5, and Eco/Comfort presets |
| Room temperature | Sensor | Current room temperature from the stove's built-in sensor (°C) |
| Power level | Sensor | Current working power level (1-5) |
| Status | Sensor | Status text (Heating, Idle, Standby, Off...) |
| Set temperature | Sensor | Current target temperature (°C) |
| Online | Binary sensor | Whether the stove is reachable via dpremoteiot.com |
| Problem | Binary sensor | Active when an alarm field or alarm/error status is reported |
| Alarm | Sensor | Cloud alarm text/code, disabled by default because fields vary by stove |
| Last seen | Sensor | Last UTC time the cloud explicitly reported the stove online |
| Operating hours | Sensor | Restored cumulative time while the stove is on |
| Pellet consumption (estimated) | Sensor | Restored consumption estimate based on runtime and power, disabled by default |
| Power level | Number | Slider to adjust power level (1-5) |

The cloud can briefly report a healthy stove offline during its periodic heartbeat.
Expand Down Expand Up @@ -287,3 +287,4 @@ Restart Home Assistant and view the logs:
- **Power level may be ignored on startup** — some stove models always start at a fixed power level regardless of the `settedPower` value. This is a stove firmware limitation, not a bug in this integration.
- **Cloud dependency** — requires internet connectivity (communicates via dpremoteiot.com, not locally).
- **HTML scraping fallback** — state detection primarily uses JSON data embedded in the dashboard HTML, with regex parsing as a fallback. If dpremoteiot.com changes their layout, the integration may need updating.
- **Estimated pellet use** — consumption is calculated from configured rates and observed power, not measured by the stove. Gaps are capped to avoid counting through long outages or restarts.
14 changes: 9 additions & 5 deletions custom_components/duepi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession

from .api import DuepiAuthError, DuepiCloudClient, DuepiConnectionError
from .api import DuepiCloudClient, DuepiConnectionError
from .const import (
CONF_DEVICE_ID,
CONF_DEFAULT_POWER,
CONF_DEFAULT_TEMPERATURE,
CONF_ECO_POWER,
CONF_ECO_TEMPERATURE,
CONF_SCAN_INTERVAL,
DEFAULT_ECO_POWER,
DEFAULT_ECO_TEMPERATURE,
DEFAULT_POWER,
DEFAULT_SCAN_INTERVAL,
DEFAULT_TEMPERATURE,
Expand Down Expand Up @@ -63,16 +67,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: DuepiConfigEntry) -> boo
default_temperature=entry.options.get(
CONF_DEFAULT_TEMPERATURE, DEFAULT_TEMPERATURE
),
eco_power=entry.options.get(CONF_ECO_POWER, DEFAULT_ECO_POWER),
eco_temperature=entry.options.get(
CONF_ECO_TEMPERATURE, DEFAULT_ECO_TEMPERATURE
),
)

_LOGGER.debug("Running first data refresh (interval=%ss)", scan_interval)
await coordinator.async_config_entry_first_refresh()
except DuepiConnectionError as err:
raise ConfigEntryNotReady(f"Cannot connect to dpremoteiot.com: {err}") from err
except (DuepiAuthError, ConfigEntryAuthFailed):
raise
except Exception:
raise

_LOGGER.info("Duepi integration ready")

Expand Down
27 changes: 27 additions & 0 deletions custom_components/duepi/accumulation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Pure accumulation helpers for Duepi runtime sensors."""

from __future__ import annotations


def pellet_rate(
power: int | None,
kg_per_hour_min: float,
kg_per_hour_max: float,
) -> float:
"""Estimate pellet use for a valid power level from one through five."""
if power is None or not 1 <= power <= 5:
return 0.0
position = (power - 1) / 4
return kg_per_hour_min + position * (kg_per_hour_max - kg_per_hour_min)


def capped_elapsed_seconds(
previous_tick: float | None,
current_tick: float,
scan_interval_seconds: float,
) -> float:
"""Return non-negative elapsed time capped at two polling intervals."""
if previous_tick is None:
return 0.0
elapsed = max(0.0, current_tick - previous_tick)
return min(elapsed, max(0.0, 2 * scan_interval_seconds))
Loading
Loading