violet-poolController-api 0.0.39__tar.gz → 0.0.40__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.
Files changed (35) hide show
  1. {violet_poolcontroller_api-0.0.39/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.40}/PKG-INFO +1 -1
  2. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/pyproject.toml +1 -1
  3. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/tests/test_api.py +38 -18
  4. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/tests/test_mock_server.py +16 -0
  5. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40/violet_poolController_api.egg-info}/PKG-INFO +1 -1
  6. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_readings.py +11 -2
  7. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/const_api.py +8 -0
  8. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/LICENSE +0 -0
  9. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/README.md +0 -0
  10. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/setup.cfg +0 -0
  11. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/tests/test_api_smoke.py +0 -0
  12. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/tests/test_circuit_breaker.py +0 -0
  13. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/tests/test_language_policy.py +0 -0
  14. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/tests/test_parsers.py +0 -0
  15. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/tests/test_rate_limiter.py +0 -0
  16. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/tests/test_readings.py +0 -0
  17. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/tests/test_sanitizer.py +0 -0
  18. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolController_api.egg-info/SOURCES.txt +0 -0
  19. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  20. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolController_api.egg-info/requires.txt +0 -0
  21. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolController_api.egg-info/top_level.txt +0 -0
  22. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/__init__.py +0 -0
  23. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_dosing.py +0 -0
  24. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_mixin.py +0 -0
  25. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_model.py +0 -0
  26. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_outputs.py +0 -0
  27. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_system.py +0 -0
  28. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/api.py +0 -0
  29. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/circuit_breaker.py +0 -0
  30. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/const_devices.py +0 -0
  31. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/parsers.py +0 -0
  32. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/py.typed +0 -0
  33. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/readings.py +0 -0
  34. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/utils_rate_limiter.py +0 -0
  35. {violet_poolcontroller_api-0.0.39 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/utils_sanitizer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.39
3
+ Version: 0.0.40
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "violet-poolController-api"
7
- version = "0.0.39"
7
+ version = "0.0.40"
8
8
  authors = [
9
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
10
10
  ]
@@ -42,6 +42,7 @@ from violet_poolcontroller_api.const_api import (
42
42
  ERROR_SEVERITY_INFO,
43
43
  ERROR_SEVERITY_REMINDER,
44
44
  ERROR_SEVERITY_WARNING,
45
+ QUERY_FULL_REFRESH,
45
46
  TARGET_PH,
46
47
  )
47
48
 
@@ -92,7 +93,7 @@ async def test_get_readings_success(
92
93
  api_client: VioletPoolAPI,
93
94
  ) -> None:
94
95
  """Test get_readings returns the correct parsed JSON dictionary."""
95
- url = "http://192.168.1.100/getReadings?ALL"
96
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
96
97
  mock_data = {"PUMPSTATE": "2", "PH": 7.2}
97
98
  mock_aioresponse.get(url, payload=mock_data, status=200)
98
99
 
@@ -102,6 +103,25 @@ async def test_get_readings_success(
102
103
  assert dict(result) == mock_data
103
104
 
104
105
 
106
+ @pytest.mark.asyncio
107
+ async def test_get_readings_requests_all_feature_flags(
108
+ mock_aioresponse: aioresponses,
109
+ api_client: VioletPoolAPI,
110
+ ) -> None:
111
+ """Plain ``ALL`` omits the computed fields (daily dosing totals, runtime
112
+ strings, priority-state composites) on some firmware states, leaving the
113
+ matching sensors at ``unknown``. get_readings() must therefore always
114
+ ask for ``ALL`` combined with every feature-flag token."""
115
+ url = f"http://192.168.1.100/getReadings?{QUERY_FULL_REFRESH}"
116
+ mock_aioresponse.get(url, payload={"PUMPSTATE": "2"}, status=200)
117
+
118
+ await api_client.get_readings()
119
+
120
+ # aioresponses matches the URL exactly; reaching this point without an
121
+ # unsatisfied-mock error proves the full query string was sent.
122
+ assert mock_aioresponse.requests
123
+
124
+
105
125
  @pytest.mark.asyncio
106
126
  async def test_get_output_runtimes_success(
107
127
  mock_aioresponse: aioresponses,
@@ -164,7 +184,7 @@ async def test_request_server_error(
164
184
  api_client: VioletPoolAPI,
165
185
  ) -> None:
166
186
  """Test that a 500 error raises VioletPoolAPIError after retrying."""
167
- url = "http://192.168.1.100/getReadings?ALL"
187
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
168
188
  mock_aioresponse.get(url, status=500)
169
189
  # the second time it retries
170
190
  mock_aioresponse.get(url, status=500)
@@ -358,7 +378,7 @@ async def test_get_readings_standalone_list_format(
358
378
  api_client: VioletPoolAPI,
359
379
  ) -> None:
360
380
  """Test get_readings parses the standalone list format correctly."""
361
- url = "http://192.168.1.100/getReadings?ALL"
381
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
362
382
  mock_data = {
363
383
  "getReadings": [
364
384
  {
@@ -394,7 +414,7 @@ async def test_dosing_standalone_detection_dict_format(
394
414
  standalone_api_client: VioletPoolAPI,
395
415
  ) -> None:
396
416
  """Test dosing_standalone is set to False when dict format is received."""
397
- url = "http://192.168.1.100/getReadings?ALL"
417
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
398
418
  mock_data = {
399
419
  "getReadings": {
400
420
  "PUMPSTATE": "2",
@@ -414,7 +434,7 @@ async def test_dosing_standalone_detection_dict_format(
414
434
  @pytest.mark.asyncio
415
435
  async def test_get_hardware_profile(mock_aioresponse, api_client):
416
436
  """Test get_hardware_profile correctly detects components via alive counters."""
417
- url = "http://192.168.1.100/getReadings?ALL"
437
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
418
438
 
419
439
  # 1. Base module only (no DOS, EXT)
420
440
  mock_aioresponse.get(
@@ -524,7 +544,7 @@ async def test_module_alive_on_zero_count(mock_aioresponse, api_client):
524
544
  old code required value > 0, which caused EXT1_* readings to be filtered
525
545
  and the relay switch to appear broken right after a restart.
526
546
  """
527
- url = "http://192.168.1.100/getReadings?ALL"
547
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
528
548
  mock_aioresponse.get(
529
549
  url,
530
550
  payload={
@@ -548,7 +568,7 @@ async def test_module_alive_on_zero_count(mock_aioresponse, api_client):
548
568
  @pytest.mark.asyncio
549
569
  async def test_ext1_readings_not_filtered_when_detected(mock_aioresponse, api_client):
550
570
  """EXT1_* readings are included when extension_module_1 is detected."""
551
- url = "http://192.168.1.100/getReadings?ALL"
571
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
552
572
  mock_aioresponse.get(
553
573
  url,
554
574
  payload={
@@ -572,7 +592,7 @@ async def test_ext1_readings_not_filtered_when_detected(mock_aioresponse, api_cl
572
592
  @pytest.mark.asyncio
573
593
  async def test_ext1_readings_filtered_when_not_detected(mock_aioresponse, api_client):
574
594
  """EXT1_* readings are stripped when extension_module_1 key is absent."""
575
- url = "http://192.168.1.100/getReadings?ALL"
595
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
576
596
  mock_aioresponse.get(
577
597
  url,
578
598
  payload={
@@ -594,7 +614,7 @@ async def test_ext1_readings_filtered_when_not_detected(mock_aioresponse, api_cl
594
614
  @pytest.mark.asyncio
595
615
  async def test_get_hardware_profile_standalone_dosing(mock_aioresponse, standalone_api_client):
596
616
  """Test get_hardware_profile with a standalone dosing configuration."""
597
- url = "http://192.168.1.100/getReadings?ALL"
617
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
598
618
  # Using the standalone list format
599
619
  mock_data = {
600
620
  "getReadings": [
@@ -1874,7 +1894,7 @@ async def test_client_error_fails_fast_without_retry(
1874
1894
  api_client: VioletPoolAPI,
1875
1895
  ) -> None:
1876
1896
  """A 4xx response raises immediately instead of being retried."""
1877
- url = "http://192.168.1.100/getReadings?ALL"
1897
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1878
1898
  mock_aioresponse.get(url, status=401, body="Unauthorized")
1879
1899
 
1880
1900
  with pytest.raises(VioletPoolAPIError, match="HTTP 401"):
@@ -1887,7 +1907,7 @@ async def test_client_error_does_not_trip_circuit_breaker(
1887
1907
  api_client: VioletPoolAPI,
1888
1908
  ) -> None:
1889
1909
  """Deterministic 4xx errors must not count as circuit breaker failures."""
1890
- url = "http://192.168.1.100/getReadings?ALL"
1910
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1891
1911
  threshold = api_client._circuit_breaker.failure_threshold
1892
1912
 
1893
1913
  for _ in range(threshold + 1):
@@ -1907,7 +1927,7 @@ async def test_empty_get_readings_payload_is_flattened(
1907
1927
  mock_aioresponse: aioresponses,
1908
1928
  api_client: VioletPoolAPI,
1909
1929
  ) -> None:
1910
- url = "http://192.168.1.100/getReadings?ALL"
1930
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1911
1931
  mock_aioresponse.get(url, payload={"getReadings": {}}, status=200)
1912
1932
 
1913
1933
  readings = await api_client.get_readings()
@@ -1937,7 +1957,7 @@ async def test_basic_auth_uses_authorization_header(
1937
1957
  mock_aioresponse: aioresponses,
1938
1958
  api_client: VioletPoolAPI,
1939
1959
  ) -> None:
1940
- url = "http://192.168.1.100/getReadings?ALL"
1960
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1941
1961
  mock_aioresponse.get(url, payload={}, status=200)
1942
1962
 
1943
1963
  await api_client.get_readings()
@@ -1953,7 +1973,7 @@ async def test_server_error_still_counts_for_circuit_breaker(
1953
1973
  api_client: VioletPoolAPI,
1954
1974
  ) -> None:
1955
1975
  """5xx errors keep counting as circuit breaker failures."""
1956
- url = "http://192.168.1.100/getReadings?ALL"
1976
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1957
1977
  mock_aioresponse.get(url, status=500, body="boom", repeat=True)
1958
1978
 
1959
1979
  with pytest.raises(VioletPoolAPIError):
@@ -1976,7 +1996,7 @@ async def test_rate_limiter_reacquired_on_every_retry(
1976
1996
  retry loop, so retries fired real HTTP requests without ever going
1977
1997
  through the limiter again.
1978
1998
  """
1979
- url = "http://192.168.1.100/getReadings?ALL"
1999
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1980
2000
  mock_aioresponse.get(url, status=500, body="boom", repeat=True)
1981
2001
  # Backoff sleeps aren't what this test verifies; skip them for speed.
1982
2002
  monkeypatch.setattr("violet_poolcontroller_api.api.asyncio.sleep", AsyncMock())
@@ -2259,7 +2279,7 @@ async def test_reads_are_still_retried(
2259
2279
  mock_aioresponse: aioresponses,
2260
2280
  ) -> None:
2261
2281
  """The read path keeps its retries; repeating a read costs nothing."""
2262
- url = "http://192.168.1.100/getReadings?ALL"
2282
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
2263
2283
  mock_aioresponse.get(url, status=500, body="boom")
2264
2284
  mock_aioresponse.get(url, status=500, body="boom")
2265
2285
  mock_aioresponse.get(url, payload={"getReadings": {"PUMP": "1"}}, status=200)
@@ -2282,7 +2302,7 @@ async def test_rate_limit_wait_timeout_fails_instead_of_bypassing(
2282
2302
  Sending anyway let every caller that waited out the timeout hit the
2283
2303
  controller at once, which is the pile-up the limiter exists to prevent.
2284
2304
  """
2285
- url = "http://192.168.1.100/getReadings?ALL"
2305
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
2286
2306
  mock_aioresponse.get(url, payload={"getReadings": {}}, status=200)
2287
2307
 
2288
2308
  async with aiohttp.ClientSession() as session:
@@ -2308,7 +2328,7 @@ async def test_html_login_page_does_not_open_the_circuit_breaker(
2308
2328
  Counting those as transient failures opened the breaker and replaced the
2309
2329
  payload error that explains the actual problem.
2310
2330
  """
2311
- url = "http://192.168.1.100/getReadings?ALL"
2331
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
2312
2332
  for _ in range(8):
2313
2333
  mock_aioresponse.get(url, body="<html>login</html>", status=200)
2314
2334
 
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import base64
11
+ import json
11
12
  import socket
12
13
  import subprocess
13
14
  import sys
@@ -89,6 +90,21 @@ async def test_raw_auth() -> None:
89
90
  assert status == 200, f"Expected 200, got {status}"
90
91
  print(f" OK: status={status} body_len={len(body)}")
91
92
 
93
+ print()
94
+ print("=" * 60)
95
+ print("TEST 1b: ALL plus feature-flag tokens must not filter the payload")
96
+ print("=" * 60)
97
+ status_mixed, body_mixed = await _request(
98
+ f"http://{HOST}:{PORT}/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
99
+ )
100
+ assert status_mixed == 200, f"Expected 200, got {status_mixed}"
101
+ # build_readings() re-randomizes sensor values per call, so compare the
102
+ # key set: ALL + tokens must return the same unfiltered key set as ALL.
103
+ keys_all = set(json.loads(body)["getReadings"])
104
+ keys_mixed = set(json.loads(body_mixed)["getReadings"])
105
+ assert keys_mixed == keys_all, "ALL + tokens must not filter any keys"
106
+ print(f" OK: status={status_mixed} keys={len(keys_mixed)} (same as ALL)")
107
+
92
108
  print()
93
109
  print("=" * 60)
94
110
  print("TEST 2: Raw HTTP - /getConfig without credentials -> 401")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.39
3
+ Version: 0.0.40
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -14,6 +14,7 @@ from .const_api import (
14
14
  API_GET_WEATHER_DATA,
15
15
  API_PRIORITY_LOW,
16
16
  API_READINGS,
17
+ QUERY_FULL_REFRESH,
17
18
  )
18
19
  from .readings import VioletReadings
19
20
 
@@ -81,6 +82,14 @@ class ReadingsMixin(APIClientMixin):
81
82
  to work unchanged. Typed properties (``readings.pump``,
82
83
  ``readings.ph``, etc.) are available as an additive convenience.
83
84
 
85
+ The request uses ``ALL`` combined with every feature-flag token
86
+ (``DOSAGE``, ``RUNTIMES``, ``PUMPPRIOSTATE``, ``BACKWASH``,
87
+ ``SYSTEM``): plain ``ALL`` omits the computed fields these tokens
88
+ unlock — daily dosing totals, runtime strings, priority-state
89
+ composites, backwash counters, SYSTEM diagnostics — which leaves the
90
+ corresponding sensors at ``unknown`` (firmware ``getReadings.js``,
91
+ observed after a controller restart).
92
+
84
93
  Returns:
85
94
  A :class:`VioletReadings` instance wrapping all readings.
86
95
 
@@ -90,7 +99,7 @@ class ReadingsMixin(APIClientMixin):
90
99
  """
91
100
  response = await self._request_json_dict(
92
101
  API_READINGS,
93
- query="ALL",
102
+ query=QUERY_FULL_REFRESH,
94
103
  payload_name="getReadings",
95
104
  )
96
105
  flat = self._flatten_getreadings_response(response)
@@ -109,7 +118,7 @@ class ReadingsMixin(APIClientMixin):
109
118
  """
110
119
  response = await self._request_json_dict(
111
120
  API_READINGS,
112
- query="ALL",
121
+ query=QUERY_FULL_REFRESH,
113
122
  payload_name="getReadings",
114
123
  )
115
124
  # Use flat dict directly (VioletReadings wrapping not needed here)
@@ -109,6 +109,11 @@ LOG_TYPES = frozenset({LOG_TYPE_ACTIONS, LOG_TYPE_SWITCHING, LOG_TYPE_ONEWIRE})
109
109
  # Mixing ``ALL`` with such prefixes is fine but redundant.
110
110
  #
111
111
  # Source: includes/getReadings.js (firmware 1.0.9).
112
+ # Feature-flag tokens of the query language above. Plain ``ALL`` omits
113
+ # every field these tokens unlock, so a "give me everything" refresh must
114
+ # always combine ``ALL`` with all of them.
115
+ FEATURE_FLAG_TOKENS = ("DOSAGE", "RUNTIMES", "PUMPPRIOSTATE", "BACKWASH", "SYSTEM")
116
+
112
117
  SPECIFIC_READING_GROUPS = (
113
118
  "ADC",
114
119
  "DOSAGE",
@@ -125,6 +130,9 @@ SPECIFIC_READING_GROUPS = (
125
130
  )
126
131
  SPECIFIC_FULL_REFRESH_INTERVAL = 10 # Number of updates before a full refresh
127
132
 
133
+ # Complete-dataset query: base READINGS plus every computed field group.
134
+ QUERY_FULL_REFRESH = ",".join(("ALL", *FEATURE_FLAG_TOKENS))
135
+
128
136
  # =============================================================================
129
137
  # API ACTIONS
130
138
  # =============================================================================