dimplex-controller 0.13.1__tar.gz → 0.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.13.1
3
+ Version: 0.14.0
4
4
  Summary: Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex
5
- License: MIT
6
- License-File: LICENSE
7
5
  Keywords: dimplex,heating,control,iot,asyncio
8
6
  Author: Kieran Roper
9
- Requires-Python: >=3.10,<4.0
7
+ License-Expression: MIT
8
+ License-File: LICENSE
10
9
  Classifier: Development Status :: 5 - Production/Stable
11
10
  Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: MIT License
13
11
  Classifier: Natural Language :: English
14
12
  Classifier: Operating System :: OS Independent
15
13
  Classifier: Programming Language :: Python :: 3
@@ -19,8 +17,9 @@ Classifier: Programming Language :: Python :: 3.12
19
17
  Classifier: Programming Language :: Python :: 3.13
20
18
  Classifier: Programming Language :: Python :: 3.14
21
19
  Classifier: Topic :: Home Automation
22
- Requires-Dist: aiohttp (>=3.9.0,<4.0.0)
23
- Requires-Dist: pydantic (>=2.0.0,<3.0.0)
20
+ Requires-Dist: aiohttp>=3.14.3,<4
21
+ Requires-Dist: pydantic>=2.13.5,<3
22
+ Requires-Python: >=3.10, <4.0
24
23
  Project-URL: Homepage, https://github.com/KRoperUK/dimplex-controller-py
25
24
  Project-URL: Repository, https://github.com/KRoperUK/dimplex-controller-py
26
25
  Description-Content-Type: text/markdown
@@ -104,9 +103,12 @@ pip install .
104
103
  ```bash
105
104
  git clone https://github.com/KRoperUK/dimplex-controller-py.git
106
105
  cd dimplex-controller-py
107
- pip install -e ".[dev]"
106
+ uv sync
108
107
  ```
109
108
 
109
+ `uv sync` creates `.venv`, installs the library in editable mode, and installs the
110
+ dev dependency group from `uv.lock`.
111
+
110
112
  > **Requires:** Python 3.10 or later.
111
113
 
112
114
  ## Quick start
@@ -303,7 +305,7 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
303
305
 
304
306
  - **`DimplexError`** — Base exception.
305
307
  - **`DimplexAuthError`** — Authentication or token errors.
306
- - **`DimplexApiError`** — API returned a non-success status. Contains `status` and `message`.
308
+ - **`DimplexApiError`** — API returned a non-success status. Contains `status`, `message`, `transient` (`True` for a 5xx/429 raised after retries were exhausted, so a caller can back off rather than surface it) and an optional `code`.
307
309
  - **`DimplexConnectionError`** — Network-level failures.
308
310
 
309
311
  ## Troubleshooting
@@ -400,4 +402,3 @@ Key points:
400
402
  ## Changelog
401
403
 
402
404
  See [CHANGELOG.md](CHANGELOG.md) for version history.
403
-
@@ -77,9 +77,12 @@ pip install .
77
77
  ```bash
78
78
  git clone https://github.com/KRoperUK/dimplex-controller-py.git
79
79
  cd dimplex-controller-py
80
- pip install -e ".[dev]"
80
+ uv sync
81
81
  ```
82
82
 
83
+ `uv sync` creates `.venv`, installs the library in editable mode, and installs the
84
+ dev dependency group from `uv.lock`.
85
+
83
86
  > **Requires:** Python 3.10 or later.
84
87
 
85
88
  ## Quick start
@@ -276,7 +279,7 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
276
279
 
277
280
  - **`DimplexError`** — Base exception.
278
281
  - **`DimplexAuthError`** — Authentication or token errors.
279
- - **`DimplexApiError`** — API returned a non-success status. Contains `status` and `message`.
282
+ - **`DimplexApiError`** — API returned a non-success status. Contains `status`, `message`, `transient` (`True` for a 5xx/429 raised after retries were exhausted, so a caller can back off rather than surface it) and an optional `code`.
280
283
  - **`DimplexConnectionError`** — Network-level failures.
281
284
 
282
285
  ## Troubleshooting
@@ -274,12 +274,14 @@ class DimplexControl:
274
274
  attempts - 1,
275
275
  delay,
276
276
  )
277
- last_error = DimplexApiError(resp.status, text)
277
+ last_error = DimplexApiError(resp.status, text, transient=resp.status in _RETRYABLE_STATUS)
278
278
  await asyncio.sleep(delay)
279
279
  continue
280
280
 
281
281
  _LOGGER.error("API request failed: %s - %s", resp.status, text)
282
- raise DimplexApiError(resp.status, text)
282
+ # A 5xx/429 that reaches here was retried and still failed, so it
283
+ # is transient (back off); a 4xx is a hard failure (surface it).
284
+ raise DimplexApiError(resp.status, text, transient=resp.status in _RETRYABLE_STATUS)
283
285
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
284
286
  if allow_retry and attempt + 1 < attempts:
285
287
  delay = self._backoff_seconds(attempt)
@@ -79,11 +79,31 @@ class DimplexAuthTransientError(DimplexAuthError):
79
79
 
80
80
 
81
81
  class DimplexApiError(DimplexError):
82
- """Exception for API errors."""
82
+ """Exception for API errors.
83
83
 
84
- def __init__(self, status: int, message: str):
84
+ Attributes:
85
+ status: HTTP status returned by the API.
86
+ message: Short server message (never a full token).
87
+ transient: ``True`` when the failure may succeed on retry — a 5xx or 429
88
+ raised only after ``client.py`` exhausted its internal retries. ``False``
89
+ for a hard 4xx (404/400/403). Callers (e.g. the Home Assistant
90
+ coordinator) back off and keep entities available on ``transient``, and
91
+ surface the error otherwise.
92
+ code: Optional stable, machine-readable identifier.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ status: int,
98
+ message: str,
99
+ *,
100
+ transient: bool = False,
101
+ code: str | None = None,
102
+ ):
85
103
  self.status = status
86
104
  self.message = message
105
+ self.transient = transient
106
+ self.code = code
87
107
  super().__init__(f"API Error {status}: {message}")
88
108
 
89
109
 
@@ -287,6 +287,14 @@ class ApplianceStatus(BaseModel):
287
287
  ErrorCode: str | None = None
288
288
  WarningCode: str | None = None
289
289
 
290
+ @field_validator("ErrorCode", "WarningCode", mode="before")
291
+ @classmethod
292
+ def _coerce_code(cls, value: Any) -> Any:
293
+ # GetApplianceOverview sends these as integers (0 when healthy).
294
+ if isinstance(value, int) and not isinstance(value, bool):
295
+ return str(value)
296
+ return value
297
+
290
298
  @property
291
299
  def mode_flags(self) -> ApplianceModeFlag:
292
300
  """Return ``ApplianceModes`` as a typed flag set."""
@@ -117,6 +117,15 @@ def _coerce_timestamp(raw: Any) -> datetime | None:
117
117
  return raw
118
118
  if isinstance(raw, int | float):
119
119
  ts = float(raw)
120
+ # Seconds-vs-milliseconds disambiguation is magnitude-only, which is safe for
121
+ # the data this parses: a Unix *seconds* epoch does not reach 1e12 until the
122
+ # year ~33658, and the cloud emits second-resolution `TS` values, so anything
123
+ # above the threshold is a millisecond epoch. The converse edge is not
124
+ # defended — a millisecond epoch before 2001-09-09 (< 1e12) would be misread
125
+ # as seconds — because the cloud has never produced one. A value just below
126
+ # 1e12 read as seconds lands past datetime's representable range and is
127
+ # dropped by the guard below rather than mis-parsed. See
128
+ # dimplex-controller-py#121 for why this is intentional rather than a gap.
120
129
  if ts > 1e12:
121
130
  ts = ts / 1000.0
122
131
  try:
@@ -276,6 +285,11 @@ def summarise_energy(
276
285
  ``points`` may be raw cloud payloads or already-parsed
277
286
  :data:`TelemetryPoint` tuples. When raw, they are passed through
278
287
  :func:`parse_telemetry_points` first.
288
+
289
+ Points with no parseable timestamp (``ts is None``) are excluded from the
290
+ total in **every** mode. They cannot be placed on a calendar day, so
291
+ counting them only in ``lifetime`` would make the cumulative total disagree
292
+ with the sum of the per-day totals (dimplex-controller-py#120).
279
293
  """
280
294
  parsed: list[TelemetryPoint]
281
295
  if (
@@ -306,8 +320,16 @@ def summarise_energy(
306
320
  mode="daily",
307
321
  )
308
322
 
309
- # lifetime — include every parseable point; window — optional start/end filter
310
- selected = filter_telemetry_points(parsed, start=start, end=end, tz=tz) if mode == "window" else list(parsed)
323
+ # lifetime / window — totals must reconcile with the per-day totals, so a point
324
+ # with no parseable timestamp is dropped from both. A ``ts is None`` point cannot
325
+ # be placed on any calendar day (``daily``/``window`` already drop it via
326
+ # ``filter_telemetry_points``), so counting it only in the lifetime total made the
327
+ # cumulative meter disagree with the sum of its days — silent drift into the HA
328
+ # Energy Dashboard's TOTAL_INCREASING sensor (dimplex-controller-py#120).
329
+ if mode == "window":
330
+ selected = filter_telemetry_points(parsed, start=start, end=end, tz=tz)
331
+ else:
332
+ selected = [(ts, value) for ts, value in parsed if ts is not None]
311
333
 
312
334
  timestamps = [ts for ts, _ in selected if ts is not None]
313
335
  total = sum(v for _, v in selected)
@@ -0,0 +1,96 @@
1
+ [project]
2
+ name = "dimplex-controller"
3
+ version = "0.14.0"
4
+ description = "Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ readme = "README.md"
8
+ requires-python = ">=3.10,<4.0"
9
+ keywords = [
10
+ "dimplex",
11
+ "heating",
12
+ "control",
13
+ "iot",
14
+ "asyncio",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Developers",
19
+ "Natural Language :: English",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Topic :: Home Automation",
28
+ ]
29
+ dependencies = [
30
+ "aiohttp>=3.14.3,<4",
31
+ "pydantic>=2.13.5,<3",
32
+ ]
33
+
34
+ [[project.authors]]
35
+ name = "Kieran Roper"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/KRoperUK/dimplex-controller-py"
39
+ Repository = "https://github.com/KRoperUK/dimplex-controller-py"
40
+
41
+ [project.scripts]
42
+ dimplex = "dimplex_controller.cli:main"
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "pytest>=9.1.1,<10",
47
+ "pytest-asyncio>=1.4.0,<2",
48
+ "pytest-cov>=7.1.0,<8",
49
+ "aresponses>=3.0.0,<4",
50
+ "ruff>=0.16.7,<0.17",
51
+ "pre-commit>=4.6.2,<5",
52
+ "twine>=7.0.0,<8",
53
+ "python-dotenv>=1.2.3,<2",
54
+ "mypy>=2.3.1,<3",
55
+ ]
56
+
57
+ [tool.ruff]
58
+ line-length = 120
59
+ target-version = "py310"
60
+ extend-exclude = ["*.md"]
61
+
62
+ [tool.ruff.lint]
63
+ select = [
64
+ "E",
65
+ "F",
66
+ "I",
67
+ "W",
68
+ "UP",
69
+ "B",
70
+ "SIM",
71
+ ]
72
+ ignore = ["E501"]
73
+
74
+ [tool.mypy]
75
+ python_version = "3.10"
76
+ files = ["dimplex_controller"]
77
+ ignore_missing_imports = true
78
+ strict = true
79
+
80
+ [tool.pytest.ini_options]
81
+ asyncio_mode = "auto"
82
+ testpaths = ["tests"]
83
+
84
+ [tool.coverage.run]
85
+ source = ["dimplex_controller"]
86
+
87
+ [tool.coverage.report]
88
+ show_missing = true
89
+ fail_under = 80
90
+
91
+ [tool.uv.build-backend]
92
+ module-root = ""
93
+
94
+ [build-system]
95
+ requires = ["uv_build>=0.11.19,<0.13.0"]
96
+ build-backend = "uv_build"
@@ -1,17 +1,16 @@
1
- [tool.poetry]
1
+ [project]
2
2
  name = "dimplex-controller"
3
- version = "0.13.1"
3
+ version = "0.14.0"
4
4
  description = "Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex"
5
- authors = ["Kieran Roper"]
5
+ authors = [{ name = "Kieran Roper" }]
6
6
  license = "MIT"
7
+ license-files = ["LICENSE"]
7
8
  readme = "README.md"
8
- homepage = "https://github.com/KRoperUK/dimplex-controller-py"
9
- repository = "https://github.com/KRoperUK/dimplex-controller-py"
9
+ requires-python = ">=3.10,<4.0"
10
10
  keywords = ["dimplex", "heating", "control", "iot", "asyncio"]
11
11
  classifiers = [
12
12
  "Development Status :: 5 - Production/Stable",
13
13
  "Intended Audience :: Developers",
14
- "License :: OSI Approved :: MIT License",
15
14
  "Natural Language :: English",
16
15
  "Operating System :: OS Independent",
17
16
  "Programming Language :: Python :: 3",
@@ -19,32 +18,48 @@ classifiers = [
19
18
  "Programming Language :: Python :: 3.11",
20
19
  "Programming Language :: Python :: 3.12",
21
20
  "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
22
  "Topic :: Home Automation",
23
23
  ]
24
- packages = [{ include = "dimplex_controller" }]
24
+ # Lower bounds are the versions this release was developed and tested against;
25
+ # the upper bounds keep the previous caret semantics (a caret range is
26
+ # `>=X,<next-major`), so consumers gain no surprise from the migration.
27
+ dependencies = [
28
+ "aiohttp>=3.14.3,<4",
29
+ "pydantic>=2.13.5,<3",
30
+ ]
25
31
 
26
- [tool.poetry.scripts]
27
- dimplex = "dimplex_controller.cli:main"
32
+ [project.urls]
33
+ Homepage = "https://github.com/KRoperUK/dimplex-controller-py"
34
+ Repository = "https://github.com/KRoperUK/dimplex-controller-py"
28
35
 
29
- [tool.poetry.dependencies]
30
- python = "^3.10"
31
- aiohttp = "^3.9.0"
32
- pydantic = "^2.0.0"
36
+ [project.scripts]
37
+ dimplex = "dimplex_controller.cli:main"
33
38
 
34
- [tool.poetry.group.dev.dependencies]
35
- pytest = "^8.0.0"
36
- pytest-asyncio = "^1.0.0"
37
- pytest-cov = "^6.0.0"
38
- aresponses = "^3.0.0"
39
- ruff = "^0.15.0"
40
- pre-commit = "^3.6.0"
41
- twine = "^6.2.0"
42
- python-dotenv = "^1.2.1"
43
- mypy = "^1.0.0"
39
+ # PEP 735 dependency groups: not installed into a consumer's environment, only
40
+ # into a development one.
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=9.1.1,<10",
44
+ "pytest-asyncio>=1.4.0,<2",
45
+ "pytest-cov>=7.1.0,<8",
46
+ "aresponses>=3.0.0,<4",
47
+ "ruff>=0.16.7,<0.17",
48
+ "pre-commit>=4.6.2,<5",
49
+ "twine>=7.0.0,<8",
50
+ "python-dotenv>=1.2.3,<2",
51
+ "mypy>=2.3.1,<3",
52
+ ]
44
53
 
45
54
  [tool.ruff]
46
55
  line-length = 120
47
56
  target-version = "py310"
57
+ # Python inside Markdown is not formatted here. The code blocks in README.md and
58
+ # docs/ carry deliberate column alignment (see the temperature-constant table in
59
+ # docs/api-reference.md) and ruff's formatter normalises comment spacing, so
60
+ # formatting them would trade readable docs for tool uniformity. This also keeps a
61
+ # bare `ruff format .` in step with the pre-commit hooks, which are python/pyi only.
62
+ extend-exclude = ["*.md"]
48
63
 
49
64
  [tool.ruff.lint]
50
65
  select = ["E", "F", "I", "W", "UP", "B", "SIM"]
@@ -81,5 +96,11 @@ show_missing = true
81
96
  fail_under = 80
82
97
 
83
98
  [build-system]
84
- requires = ["poetry-core"]
85
- build-backend = "poetry.core.masonry.api"
99
+ requires = ["uv_build>=0.11.19,<0.13.0"]
100
+ build-backend = "uv_build"
101
+
102
+ [tool.uv.build-backend]
103
+ # The package is at the repository root, not under src/, which is what uv_build
104
+ # assumes by default. The module name is inferred from the project name
105
+ # (dimplex-controller -> dimplex_controller), so it needs no override.
106
+ module-root = ""