Skip to content

Commit 67e1ea6

Browse files
committed
DTSU666 energy meter Home Assistant integration (Modbus TCP)
Custom integration `dtsu666_meter`: UI config flow (host/port/slave/ interval), DataUpdateCoordinator, 28 sensors (per-phase + total V/A/P/Q/S, power factor, frequency, import/export energy) ready for the Energy Dashboard. Register map verified against a physical CHINT DTSU666. Includes a YAML-package fallback for the built-in modbus integration, HACS metadata, validation workflow and README.
0 parents  commit 67e1ea6

17 files changed

Lines changed: 1257 additions & 0 deletions

File tree

‎.gitattributes‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Keep LF in the repo for all text/source files (HA/Python convention)
2+
* text=auto eol=lf
3+
*.png binary
4+
*.jpg binary

‎.github/workflows/validate.yml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Validate
2+
3+
on:
4+
push:
5+
pull_request:
6+
schedule:
7+
- cron: "0 3 * * 1"
8+
workflow_dispatch:
9+
10+
jobs:
11+
hacs:
12+
name: HACS
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: hacs/action@main
17+
with:
18+
category: integration
19+
20+
hassfest:
21+
name: Hassfest
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
- uses: home-assistant/actions/hassfest@master

‎.gitignore‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
__pycache__/
2+
*.py[cod]
3+
*.egg-info/
4+
.venv/
5+
.idea/
6+
.vscode/
7+
.DS_Store
8+
*.tmp

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Developer089
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# DTSU666 Energy Meter — Home Assistant integration (Modbus TCP)
2+
3+
Home Assistant integration that reads a **CHINT DTSU666 / DTSU666-H** three-phase
4+
energy meter over **Modbus TCP** — directly or through an RS485-to-TCP gateway
5+
(default Modbus port `502`, slave/unit ID `4`).
6+
7+
It exposes 28 sensors (voltages, currents, active/reactive/apparent power per
8+
phase and total, power factor, frequency, and import/export energy counters)
9+
ready for the Home Assistant **Energy Dashboard**.
10+
11+
> The register map is **not guessed**: it was verified against a physical
12+
> CHINT DTSU666 and cross-checked against `lmatula/ha_chint_pm`,
13+
> `elfabriceu/DTSU666-Modbus` and the CHINT manual.
14+
15+
## Two ways to install
16+
17+
### A) Custom integration (recommended) — UI config, robust
18+
19+
1. Copy `custom_components/dtsu666_meter/` into your HA `config/custom_components/`
20+
(or add this repo as a HACS custom repository, category *Integration*).
21+
HACS custom repository URL: `https://github.com/Developer089/ha-dtsu666-meter`
22+
2. Restart Home Assistant.
23+
3. **Settings → Devices & Services → Add Integration → "DTSU666"**.
24+
4. Enter host / port / slave ID / polling interval. Connection is validated
25+
before the entry is created.
26+
27+
Defaults: port `502`, slave ID `4`, scan interval `10 s`. The scan interval can
28+
be changed later via the integration's **Configure** (options) button.
29+
30+
### B) YAML package (no Python, built-in `modbus`)
31+
32+
Copy `yaml_package/dtsu666_modbus.yaml` into `config/packages/`, enable packages
33+
in `configuration.yaml`:
34+
35+
```yaml
36+
homeassistant:
37+
packages: !include_dir_named packages
38+
```
39+
40+
Edit host/port/slave at the top of the file and restart. Fewer features (no UI,
41+
no graceful per-block degradation) but zero custom code.
42+
43+
## Register map (FC03 holding, FLOAT32, big-endian byte+word, no swap)
44+
45+
| Quantity | Address | Scale | Unit |
46+
|---|---|---|---|
47+
| Voltage L1-L2 / L2-L3 / L3-L1 | 0x2000 / 0x2002 / 0x2004 | ×0.1 | V |
48+
| Voltage L1 / L2 / L3 | 0x2006 / 0x2008 / 0x200A | ×0.1 | V |
49+
| Current L1 / L2 / L3 | 0x200C / 0x200E / 0x2010 | ×0.001 | A |
50+
| Active power total / L1 / L2 / L3 | 0x2012 / 0x2014 / 0x2016 / 0x2018 | ×0.1 | W |
51+
| Reactive power total / L1 / L2 / L3 | 0x201A / 0x201C / 0x201E / 0x2020 | ×0.1 | var |
52+
| Apparent power total / L1 / L2 / L3 | 0x2022 / 0x2024 / 0x2026 / 0x2028 | ×0.1 | VA |
53+
| Power factor total / L1 / L2 / L3 | 0x202A / 0x202C / 0x202E / 0x2030 | ×0.001 | – |
54+
| Frequency | 0x2044 | ×0.01 | Hz |
55+
| Energy imported | 0x401E | ×1 | kWh |
56+
| Energy exported | 0x4028 | ×1 | kWh |
57+
58+
Negative `Active power total` = export to grid. The integration reads the map in
59+
4 contiguous blocks per cycle; a single failing block degrades only its own
60+
sensors instead of taking the whole device offline.
61+
62+
## Energy Dashboard
63+
64+
`energy_import_total` and `energy_export_total` are `device_class: energy`,
65+
`state_class: total_increasing`, kWh — add them as **Grid consumption** and
66+
**Return to grid** in *Settings → Dashboards → Energy*.
67+
68+
## Requirements
69+
70+
- Home Assistant 2024.12+ (uses `entry.runtime_data`, `OptionsFlow` without
71+
passing the entry, modern typing).
72+
- `pymodbus` 3.9–3.11 — Home Assistant ships its own pinned version; the
73+
integration decodes FLOAT32 with `struct` so it does not depend on the
74+
pymodbus payload/converter API that changed across 3.9 → 3.11.
75+
76+
## Troubleshooting
77+
78+
- **Cannot connect**: verify host/port and that the slave/unit ID matches the
79+
meter (this integration defaults to `4`; other installs commonly use `1` or
80+
`11`). Only one Modbus master can talk to the gateway at a time.
81+
- **Nonsensical values**: would indicate a word-order mismatch — this map is
82+
big-endian word order, confirmed against the physical meter; no `swap`.
83+
- Enable debug logging:
84+
85+
```yaml
86+
logger:
87+
logs:
88+
custom_components.dtsu666_meter: debug
89+
```
90+
91+
## License
92+
93+
MIT — see `LICENSE`.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""DTSU666 Energy Meter (Modbus TCP) integration."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.config_entries import ConfigEntry
6+
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL, Platform
7+
from homeassistant.core import HomeAssistant
8+
9+
from .const import CONF_SLAVE_ID, DEFAULT_SCAN_INTERVAL
10+
from .coordinator import Dtsu666Coordinator
11+
12+
PLATFORMS: list[Platform] = [Platform.SENSOR]
13+
14+
type Dtsu666ConfigEntry = ConfigEntry[Dtsu666Coordinator]
15+
16+
17+
def _scan_interval(entry: Dtsu666ConfigEntry) -> int:
18+
"""Options override data; fall back to the default."""
19+
return int(
20+
entry.options.get(
21+
CONF_SCAN_INTERVAL,
22+
entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL),
23+
)
24+
)
25+
26+
27+
async def async_setup_entry(
28+
hass: HomeAssistant, entry: Dtsu666ConfigEntry
29+
) -> bool:
30+
"""Set up DTSU666 from a config entry."""
31+
coordinator = Dtsu666Coordinator(
32+
hass,
33+
entry,
34+
host=entry.data[CONF_HOST],
35+
port=entry.data[CONF_PORT],
36+
slave_id=entry.data[CONF_SLAVE_ID],
37+
scan_interval=_scan_interval(entry),
38+
)
39+
await coordinator.async_config_entry_first_refresh()
40+
41+
entry.runtime_data = coordinator
42+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
43+
entry.async_on_unload(entry.add_update_listener(_async_reload_entry))
44+
return True
45+
46+
47+
async def async_unload_entry(
48+
hass: HomeAssistant, entry: Dtsu666ConfigEntry
49+
) -> bool:
50+
"""Unload a config entry."""
51+
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
52+
if unloaded:
53+
await entry.runtime_data.async_shutdown()
54+
return unloaded
55+
56+
57+
async def _async_reload_entry(
58+
hass: HomeAssistant, entry: Dtsu666ConfigEntry
59+
) -> None:
60+
"""Reload when the user changes options (e.g. scan interval)."""
61+
await hass.config_entries.async_reload(entry.entry_id)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Config and options flow for the DTSU666 Modbus TCP integration."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
import voluptuous as vol
8+
9+
from homeassistant.config_entries import (
10+
ConfigEntry,
11+
ConfigFlow,
12+
ConfigFlowResult,
13+
OptionsFlow,
14+
)
15+
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL
16+
from homeassistant.core import callback
17+
from homeassistant.exceptions import HomeAssistantError
18+
19+
from pymodbus.client import AsyncModbusTcpClient
20+
21+
from .const import (
22+
CONF_SLAVE_ID,
23+
DEFAULT_PORT,
24+
DEFAULT_SCAN_INTERVAL,
25+
DEFAULT_SLAVE_ID,
26+
DOMAIN,
27+
MAX_SCAN_INTERVAL,
28+
MIN_SCAN_INTERVAL,
29+
)
30+
31+
32+
class CannotConnect(HomeAssistantError):
33+
"""Raised when the meter cannot be reached."""
34+
35+
36+
async def _validate(host: str, port: int, slave_id: int) -> None:
37+
"""Open a Modbus TCP session and read one register to prove it works."""
38+
client = AsyncModbusTcpClient(host, port=port, timeout=5)
39+
try:
40+
await client.connect()
41+
if not client.connected:
42+
raise CannotConnect
43+
try:
44+
rr = await client.read_holding_registers(
45+
0x2000, count=2, device_id=slave_id
46+
)
47+
except TypeError:
48+
rr = await client.read_holding_registers(
49+
0x2000, count=2, slave=slave_id
50+
)
51+
if rr.isError():
52+
raise CannotConnect
53+
except (ConnectionError, OSError) as err:
54+
raise CannotConnect from err
55+
finally:
56+
client.close()
57+
58+
59+
def _user_schema() -> vol.Schema:
60+
return vol.Schema(
61+
{
62+
vol.Required(CONF_HOST): str,
63+
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
64+
vol.Required(CONF_SLAVE_ID, default=DEFAULT_SLAVE_ID): int,
65+
vol.Required(
66+
CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
67+
): vol.All(int, vol.Range(min=MIN_SCAN_INTERVAL, max=MAX_SCAN_INTERVAL)),
68+
}
69+
)
70+
71+
72+
class Dtsu666ConfigFlow(ConfigFlow, domain=DOMAIN):
73+
"""Handle the initial UI configuration."""
74+
75+
VERSION = 1
76+
77+
async def async_step_user(
78+
self, user_input: dict[str, Any] | None = None
79+
) -> ConfigFlowResult:
80+
errors: dict[str, str] = {}
81+
if user_input is not None:
82+
await self.async_set_unique_id(
83+
f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}:"
84+
f"{user_input[CONF_SLAVE_ID]}"
85+
)
86+
self._abort_if_unique_id_configured()
87+
try:
88+
await _validate(
89+
user_input[CONF_HOST],
90+
user_input[CONF_PORT],
91+
user_input[CONF_SLAVE_ID],
92+
)
93+
except CannotConnect:
94+
errors["base"] = "cannot_connect"
95+
except Exception: # noqa: BLE001
96+
errors["base"] = "unknown"
97+
else:
98+
return self.async_create_entry(
99+
title=f"DTSU666 ({user_input[CONF_HOST]})",
100+
data=user_input,
101+
)
102+
103+
return self.async_show_form(
104+
step_id="user", data_schema=_user_schema(), errors=errors
105+
)
106+
107+
@staticmethod
108+
@callback
109+
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
110+
return Dtsu666OptionsFlow()
111+
112+
113+
class Dtsu666OptionsFlow(OptionsFlow):
114+
"""Allow changing the scan interval after setup."""
115+
116+
async def async_step_init(
117+
self, user_input: dict[str, Any] | None = None
118+
) -> ConfigFlowResult:
119+
if user_input is not None:
120+
return self.async_create_entry(title="", data=user_input)
121+
122+
current = self.config_entry.options.get(
123+
CONF_SCAN_INTERVAL,
124+
self.config_entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL),
125+
)
126+
return self.async_show_form(
127+
step_id="init",
128+
data_schema=vol.Schema(
129+
{
130+
vol.Required(
131+
CONF_SCAN_INTERVAL, default=current
132+
): vol.All(
133+
int, vol.Range(min=MIN_SCAN_INTERVAL, max=MAX_SCAN_INTERVAL)
134+
)
135+
}
136+
),
137+
)

0 commit comments

Comments
 (0)