python-bsblan 4.1.0b0__tar.gz → 4.1.0b2__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 (100) hide show
  1. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/PKG-INFO +1 -1
  2. python_bsblan-4.1.0b2/examples/speed_test.py +382 -0
  3. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/pyproject.toml +1 -1
  4. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/src/bsblan/bsblan.py +36 -8
  5. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/src/bsblan/utility.py +13 -2
  6. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_api_validation.py +13 -4
  7. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_hot_water_additional.py +135 -0
  8. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_include_parameter.py +80 -0
  9. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_info.py +3 -0
  10. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.editorconfig +0 -0
  11. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.gitattributes +0 -0
  12. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/CODE_OF_CONDUCT.md +0 -0
  13. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/CONTRIBUTING.md +0 -0
  14. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/copilot-instructions.md +0 -0
  18. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/labels.yml +0 -0
  19. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/prompts/add-parameter.prompt.md +0 -0
  20. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/prompts/code-review.prompt.md +0 -0
  21. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/release-drafter.yml +0 -0
  22. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/renovate.json +0 -0
  23. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/skills/bsblan-parameters/SKILL.md +0 -0
  24. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/skills/bsblan-testing/SKILL.md +0 -0
  25. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/auto-approve-renovate.yml +0 -0
  26. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/codeql.yaml +0 -0
  27. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/labels.yaml +0 -0
  28. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/linting.yaml +0 -0
  29. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/lock.yaml +0 -0
  30. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/pr-labels.yaml +0 -0
  31. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/release-drafter.yaml +0 -0
  32. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/release.yaml +0 -0
  33. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/stale.yaml +0 -0
  34. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/tests.yaml +0 -0
  35. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.github/workflows/typing.yaml +0 -0
  36. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.gitignore +0 -0
  37. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.nvmrc +0 -0
  38. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.pre-commit-config.yaml +0 -0
  39. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.prettierignore +0 -0
  40. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/.yamllint +0 -0
  41. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/AGENTS.md +0 -0
  42. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/CLAUDE.md +0 -0
  43. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/LICENSE.md +0 -0
  44. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/README.md +0 -0
  45. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/examples/control.py +0 -0
  46. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/examples/discovery.py +0 -0
  47. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/examples/profile_init.py +0 -0
  48. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/examples/ruff.toml +0 -0
  49. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/package-lock.json +0 -0
  50. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/package.json +0 -0
  51. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/sonar-project.properties +0 -0
  52. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/src/bsblan/__init__.py +0 -0
  53. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/src/bsblan/constants.py +0 -0
  54. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/src/bsblan/exceptions.py +0 -0
  55. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/src/bsblan/models.py +0 -0
  56. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/src/bsblan/py.typed +0 -0
  57. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/__init__.py +0 -0
  58. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/conftest.py +0 -0
  59. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/device.json +0 -0
  60. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/dict_version.json +0 -0
  61. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/hot_water_state.json +0 -0
  62. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/info.json +0 -0
  63. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/password.txt +0 -0
  64. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/sensor.json +0 -0
  65. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/state.json +0 -0
  66. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/static_state.json +0 -0
  67. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/thermostat_hvac.json +0 -0
  68. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/thermostat_temp.json +0 -0
  69. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/fixtures/time.json +0 -0
  70. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/ruff.toml +0 -0
  71. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_api_initialization.py +0 -0
  72. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_auth.py +0 -0
  73. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_backoff_retry.py +0 -0
  74. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_bsblan.py +0 -0
  75. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_bsblan_edge_cases.py +0 -0
  76. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_configuration.py +0 -0
  77. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_constants.py +0 -0
  78. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_context_manager.py +0 -0
  79. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_device.py +0 -0
  80. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_dhw_time_switch.py +0 -0
  81. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_entity_info.py +0 -0
  82. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_hotwater_state.py +0 -0
  83. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_initialization.py +0 -0
  84. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_read_parameters.py +0 -0
  85. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_reset_validation.py +0 -0
  86. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_schedule_models.py +0 -0
  87. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_sensor.py +0 -0
  88. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_set_hot_water_schedule.py +0 -0
  89. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_set_hotwater.py +0 -0
  90. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_state.py +0 -0
  91. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_static_state.py +0 -0
  92. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_temperature_unit.py +0 -0
  93. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_temperature_validation.py +0 -0
  94. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_thermostat.py +0 -0
  95. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_time.py +0 -0
  96. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_utility.py +0 -0
  97. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_utility_additional.py +0 -0
  98. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_utility_edge_cases.py +0 -0
  99. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/tests/test_version_errors.py +0 -0
  100. {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-bsblan
3
- Version: 4.1.0b0
3
+ Version: 4.1.0b2
4
4
  Summary: Asynchronous Python client for BSBLAN API
5
5
  Project-URL: Homepage, https://github.com/liudger/python-bsblan
6
6
  Project-URL: Repository, https://github.com/liudger/python-bsblan
@@ -0,0 +1,382 @@
1
+ """Test speed comparison for BSB-LAN API calls.
2
+
3
+ Compares different approaches:
4
+ 1. Multiple parallel calls (current approach)
5
+ 2. Combined read_parameters call
6
+ 3. With/without parameter filtering
7
+
8
+ Usage:
9
+ # Set environment variables (optional - will use mDNS discovery if not set)
10
+ export BSBLAN_HOST=10.0.2.60
11
+ export BSBLAN_PORT=80
12
+ export BSBLAN_PASSKEY=your_passkey # if needed
13
+
14
+ # Run the test
15
+ python examples/speed_test.py
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import statistics
22
+ import time
23
+ from dataclasses import dataclass
24
+ from typing import TYPE_CHECKING
25
+
26
+ from bsblan import BSBLAN, BSBLANConfig
27
+ from discovery import get_bsblan_host, get_config_from_env
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Awaitable, Callable
31
+
32
+ # Test configuration
33
+ NUM_WARMUP_RUNS = 2
34
+ NUM_TEST_RUNS = 10
35
+
36
+ # Parameters used in different tests
37
+ INFO_PARAMS = ["6224"] # Current firmware version
38
+ STATIC_PARAMS = ["714", "716"] # Min/max temp setpoints
39
+ ALL_PARAMS = INFO_PARAMS + STATIC_PARAMS
40
+
41
+ # Large parameter sets for scalability testing
42
+ # Heating parameters
43
+ HEATING_PARAMS = ["700", "710", "900", "8000", "8740", "8749"]
44
+
45
+ # Sensor parameters
46
+ SENSOR_PARAMS = ["8700", "8740"]
47
+
48
+ # Hot water parameters (a good mix of config and state)
49
+ HOT_WATER_PARAMS = [
50
+ "1600", # operating_mode
51
+ "1601", # eco_mode_selection
52
+ "1610", # nominal_setpoint
53
+ "1614", # nominal_setpoint_max
54
+ "1612", # reduced_setpoint
55
+ "1620", # release
56
+ "1630", # dhw_charging_priority
57
+ "1640", # legionella_function
58
+ "1641", # legionella_function_periodicity
59
+ "1642", # legionella_function_day
60
+ "1644", # legionella_function_time
61
+ "1645", # legionella_function_setpoint
62
+ "8830", # dhw_actual_value_top_temperature
63
+ "8820", # state_dhw_pump
64
+ ]
65
+
66
+ # Combined large set (~20 params)
67
+ LARGE_PARAM_SET = HEATING_PARAMS + SENSOR_PARAMS + HOT_WATER_PARAMS[:8] # ~16 params
68
+
69
+ # Extra large set (~22 params)
70
+ XLARGE_PARAM_SET = HEATING_PARAMS + SENSOR_PARAMS + HOT_WATER_PARAMS # ~22 params
71
+
72
+
73
+ @dataclass
74
+ class BenchmarkResult:
75
+ """Results from a speed benchmark."""
76
+
77
+ name: str
78
+ times: list[float]
79
+
80
+ @property
81
+ def avg(self) -> float:
82
+ """Average time."""
83
+ return statistics.mean(self.times)
84
+
85
+ @property
86
+ def min(self) -> float:
87
+ """Minimum time."""
88
+ return min(self.times)
89
+
90
+ @property
91
+ def max(self) -> float:
92
+ """Maximum time."""
93
+ return max(self.times)
94
+
95
+ @property
96
+ def median(self) -> float:
97
+ """Median time."""
98
+ return statistics.median(self.times)
99
+
100
+ @property
101
+ def stdev(self) -> float:
102
+ """Standard deviation."""
103
+ return statistics.stdev(self.times) if len(self.times) > 1 else 0.0
104
+
105
+ def __str__(self) -> str:
106
+ """Format results as string."""
107
+ return (
108
+ f"{self.name}:\n"
109
+ f" avg={self.avg:.3f}s, median={self.median:.3f}s, "
110
+ f"min={self.min:.3f}s, max={self.max:.3f}s, stdev={self.stdev:.3f}s"
111
+ )
112
+
113
+
114
+ async def run_test(
115
+ name: str,
116
+ test_fn: Callable[[], Awaitable[object]],
117
+ num_runs: int = NUM_TEST_RUNS,
118
+ warmup_runs: int = NUM_WARMUP_RUNS,
119
+ ) -> BenchmarkResult:
120
+ """Run a test function multiple times and collect timing stats.
121
+
122
+ Args:
123
+ name: Test name for display.
124
+ test_fn: Async function to test.
125
+ num_runs: Number of timed test runs.
126
+ warmup_runs: Number of warmup runs (not timed).
127
+
128
+ Returns:
129
+ BenchmarkResult with timing statistics.
130
+
131
+ """
132
+ # Warmup runs (not counted)
133
+ for _ in range(warmup_runs):
134
+ await test_fn()
135
+
136
+ # Timed runs
137
+ times: list[float] = []
138
+ for i in range(num_runs):
139
+ start = time.perf_counter()
140
+ await test_fn()
141
+ elapsed = time.perf_counter() - start
142
+ times.append(elapsed)
143
+ print(f" Run {i + 1}/{num_runs}: {elapsed:.3f}s")
144
+
145
+ return BenchmarkResult(name=name, times=times)
146
+
147
+
148
+ async def bench_3_parallel_calls(bsblan: BSBLAN) -> None:
149
+ """Benchmark 3 parallel calls (current initialization approach)."""
150
+ await asyncio.gather(
151
+ bsblan.device(),
152
+ bsblan.info(),
153
+ bsblan.static_values(),
154
+ )
155
+
156
+
157
+ async def bench_2_parallel_calls(bsblan: BSBLAN) -> None:
158
+ """Benchmark 2 calls: device + combined read_parameters."""
159
+ await asyncio.gather(
160
+ bsblan.device(),
161
+ bsblan.read_parameters(ALL_PARAMS),
162
+ )
163
+
164
+
165
+ async def bench_1_read_params(bsblan: BSBLAN) -> None:
166
+ """Benchmark single read_parameters call with all params."""
167
+ await bsblan.read_parameters(ALL_PARAMS)
168
+
169
+
170
+ async def bench_static_values_filtered(bsblan: BSBLAN) -> None:
171
+ """Benchmark static_values with include filter."""
172
+ await bsblan.static_values(include=["min_temp"])
173
+
174
+
175
+ async def bench_info_filtered(bsblan: BSBLAN) -> None:
176
+ """Benchmark info() with include filter."""
177
+ await bsblan.info(include=["device_identification"])
178
+
179
+
180
+ async def bench_large_params_single_call(bsblan: BSBLAN) -> None:
181
+ """Benchmark single call with ~16 parameters."""
182
+ await bsblan.read_parameters(LARGE_PARAM_SET)
183
+
184
+
185
+ async def bench_xlarge_params_single_call(bsblan: BSBLAN) -> None:
186
+ """Benchmark single call with ~22 parameters."""
187
+ await bsblan.read_parameters(XLARGE_PARAM_SET)
188
+
189
+
190
+ async def bench_large_params_4_parallel_calls(bsblan: BSBLAN) -> None:
191
+ """Benchmark 4 parallel calls, each with ~4 parameters."""
192
+ chunk_size = len(LARGE_PARAM_SET) // 4
193
+ chunks = [
194
+ LARGE_PARAM_SET[i : i + chunk_size]
195
+ for i in range(0, len(LARGE_PARAM_SET), chunk_size)
196
+ ]
197
+ await asyncio.gather(*[bsblan.read_parameters(chunk) for chunk in chunks])
198
+
199
+
200
+ async def bench_xlarge_params_4_parallel_calls(bsblan: BSBLAN) -> None:
201
+ """Benchmark 4 parallel calls splitting ~22 parameters."""
202
+ chunk_size = len(XLARGE_PARAM_SET) // 4
203
+ chunks = [
204
+ XLARGE_PARAM_SET[i : i + chunk_size]
205
+ for i in range(0, len(XLARGE_PARAM_SET), chunk_size)
206
+ ]
207
+ await asyncio.gather(*[bsblan.read_parameters(chunk) for chunk in chunks])
208
+
209
+
210
+ async def bench_xlarge_params_2_parallel_calls(bsblan: BSBLAN) -> None:
211
+ """Benchmark 2 parallel calls splitting ~22 parameters."""
212
+ mid = len(XLARGE_PARAM_SET) // 2
213
+ await asyncio.gather(
214
+ bsblan.read_parameters(XLARGE_PARAM_SET[:mid]),
215
+ bsblan.read_parameters(XLARGE_PARAM_SET[mid:]),
216
+ )
217
+
218
+
219
+ async def bench_info_only(bsblan: BSBLAN) -> None:
220
+ """Benchmark info() call only."""
221
+ await bsblan.info()
222
+
223
+
224
+ async def bench_static_values_only(bsblan: BSBLAN) -> None:
225
+ """Benchmark static_values() call only."""
226
+ await bsblan.static_values()
227
+
228
+
229
+ async def run_all_benchmarks(bsblan: BSBLAN) -> list[BenchmarkResult]:
230
+ """Run all speed benchmarks and return results."""
231
+ results: list[BenchmarkResult] = []
232
+
233
+ # Define basic tests
234
+ basic_tests = [
235
+ (
236
+ "Test 1: 3 parallel calls (device + info + static_values)",
237
+ "3 parallel calls",
238
+ lambda: bench_3_parallel_calls(bsblan),
239
+ ),
240
+ (
241
+ "Test 2: 2 parallel calls (device + read_parameters)",
242
+ "2 parallel calls",
243
+ lambda: bench_2_parallel_calls(bsblan),
244
+ ),
245
+ (
246
+ "Test 3: Single read_parameters call",
247
+ "1 read_parameters",
248
+ lambda: bench_1_read_params(bsblan),
249
+ ),
250
+ (
251
+ "Test 4: static_values with include filter (min_temp only)",
252
+ "static_values (filtered)",
253
+ lambda: bench_static_values_filtered(bsblan),
254
+ ),
255
+ (
256
+ "Test 5: info with include filter (device_identification only)",
257
+ "info (filtered)",
258
+ lambda: bench_info_filtered(bsblan),
259
+ ),
260
+ (
261
+ "Test 6: static_values without filter (all params)",
262
+ "static_values (all)",
263
+ lambda: bench_static_values_only(bsblan),
264
+ ),
265
+ ]
266
+
267
+ for desc, name, bench_fn in basic_tests:
268
+ print(f"\n{desc}")
269
+ result = await run_test(name, bench_fn)
270
+ results.append(result)
271
+
272
+ # Scalability tests
273
+ print("\n" + "=" * 60)
274
+ print("SCALABILITY TESTS - Many Parameters")
275
+ print("=" * 60)
276
+
277
+ scalability_tests = [
278
+ (
279
+ f"Test 7: Single call with {len(LARGE_PARAM_SET)} params",
280
+ f"1 call ({len(LARGE_PARAM_SET)} params)",
281
+ lambda: bench_large_params_single_call(bsblan),
282
+ ),
283
+ (
284
+ f"Test 8: 4 parallel calls ({len(LARGE_PARAM_SET)} params split)",
285
+ f"4 calls ({len(LARGE_PARAM_SET)} params)",
286
+ lambda: bench_large_params_4_parallel_calls(bsblan),
287
+ ),
288
+ (
289
+ f"Test 9: Single call with {len(XLARGE_PARAM_SET)} params",
290
+ f"1 call ({len(XLARGE_PARAM_SET)} params)",
291
+ lambda: bench_xlarge_params_single_call(bsblan),
292
+ ),
293
+ (
294
+ f"Test 10: 2 parallel calls ({len(XLARGE_PARAM_SET)} params split)",
295
+ f"2 calls ({len(XLARGE_PARAM_SET)} params)",
296
+ lambda: bench_xlarge_params_2_parallel_calls(bsblan),
297
+ ),
298
+ (
299
+ f"Test 11: 4 parallel calls ({len(XLARGE_PARAM_SET)} params split)",
300
+ f"4 calls ({len(XLARGE_PARAM_SET)} params)",
301
+ lambda: bench_xlarge_params_4_parallel_calls(bsblan),
302
+ ),
303
+ ]
304
+
305
+ for desc, name, bench_fn in scalability_tests:
306
+ print(f"\n{desc}")
307
+ result = await run_test(name, bench_fn)
308
+ results.append(result)
309
+
310
+ return results
311
+
312
+
313
+ def print_results(results: list[BenchmarkResult]) -> None:
314
+ """Print test results summary."""
315
+ print("\n" + "=" * 60)
316
+ print("RESULTS SUMMARY")
317
+ print("=" * 60)
318
+
319
+ for r in results:
320
+ print(f"\n{r}")
321
+
322
+ # Compare approaches
323
+ baseline = results[0].avg # 3 parallel calls
324
+ print("\n" + "-" * 60)
325
+ print("COMPARISON (vs 3 parallel calls baseline)")
326
+ print("-" * 60)
327
+
328
+ for r in results[1:]:
329
+ diff = baseline - r.avg
330
+ pct = (diff / baseline) * 100 if baseline > 0 else 0
331
+ faster_slower = "faster" if diff > 0 else "slower"
332
+ print(f"{r.name}: {abs(diff):.3f}s {faster_slower} ({abs(pct):.1f}%)")
333
+
334
+ # Best approach
335
+ best = min(results, key=lambda x: x.avg)
336
+ print(f"\n✓ Best approach: {best.name} (avg: {best.avg:.3f}s)")
337
+
338
+
339
+ async def main() -> None:
340
+ """Run speed comparison tests."""
341
+ print("=" * 60)
342
+ print("BSB-LAN API Speed Comparison Test")
343
+ print("=" * 60)
344
+
345
+ # Get configuration from environment or discovery
346
+ env_config = get_config_from_env()
347
+
348
+ try:
349
+ host, port = await get_bsblan_host(discovery_seconds=3.0)
350
+ except RuntimeError:
351
+ print("\nNo BSB-LAN device found!")
352
+ print("Set BSBLAN_HOST environment variable or enable mDNS on your device.")
353
+ return
354
+
355
+ print(f"\nConnecting to: {host}:{port}")
356
+
357
+ # Build config - cast values to str for type safety
358
+ passkey = env_config.get("passkey")
359
+ username = env_config.get("username")
360
+ password = env_config.get("password")
361
+
362
+ config = BSBLANConfig(
363
+ host=host,
364
+ port=port,
365
+ passkey=str(passkey) if passkey else None,
366
+ username=str(username) if username else None,
367
+ password=str(password) if password else None,
368
+ )
369
+
370
+ async with BSBLAN(config) as bsblan:
371
+ await bsblan.initialize()
372
+ print("✓ BSB-LAN client initialized\n")
373
+
374
+ print(f"Running {NUM_TEST_RUNS} test runs with {NUM_WARMUP_RUNS} warmup runs\n")
375
+ print("-" * 60)
376
+
377
+ results = await run_all_benchmarks(bsblan)
378
+ print_results(results)
379
+
380
+
381
+ if __name__ == "__main__":
382
+ asyncio.run(main())
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-bsblan"
3
- version = "4.1.0-beta.0"
3
+ version = "4.1.0-beta.2"
4
4
  description = "Asynchronous Python client for BSBLAN API"
5
5
  authors = [
6
6
  {name = "Willem-Jan van Rootselaar", email = "liudgervr@gmail.com"}
@@ -171,7 +171,9 @@ class BSBLAN:
171
171
  # Initialize the API validator (but don't validate sections yet)
172
172
  self._api_validator = APIValidator(self._api_data)
173
173
 
174
- async def _ensure_section_validated(self, section: SectionLiteral) -> None:
174
+ async def _ensure_section_validated(
175
+ self, section: SectionLiteral, include: list[str] | None = None
176
+ ) -> None:
175
177
  """Ensure a section is validated before use (lazy loading).
176
178
 
177
179
  This method validates a section on-demand when it's first accessed.
@@ -182,6 +184,8 @@ class BSBLAN:
182
184
 
183
185
  Args:
184
186
  section: The section name to validate
187
+ include: Optional list of parameter names to validate. If None,
188
+ validates all parameters for the section.
185
189
 
186
190
  """
187
191
  if not self._api_validator:
@@ -201,7 +205,7 @@ class BSBLAN:
201
205
  return
202
206
 
203
207
  logger.debug("Lazy loading section: %s", section)
204
- response_data = await self._validate_api_section(section)
208
+ response_data = await self._validate_api_section(section, include)
205
209
 
206
210
  # Extract temperature unit from heating section validation
207
211
  # (parameter 710 - target_temperature is always in heating section)
@@ -209,7 +213,10 @@ class BSBLAN:
209
213
  self._extract_temperature_unit_from_response(response_data)
210
214
 
211
215
  async def _ensure_hot_water_group_validated(
212
- self, group_name: str, param_filter: set[str]
216
+ self,
217
+ group_name: str,
218
+ param_filter: set[str],
219
+ include: list[str] | None = None,
213
220
  ) -> None:
214
221
  """Validate only a specific hot water parameter group (lazy loading).
215
222
 
@@ -223,6 +230,8 @@ class BSBLAN:
223
230
  Args:
224
231
  group_name: Name of the group (essential, config, schedule)
225
232
  param_filter: Set of parameter IDs for this group
233
+ include: Optional list of parameter names to include in validation.
234
+ If provided, only these parameters will be validated.
226
235
 
227
236
  """
228
237
  # Fast path: skip if already validated (no lock needed)
@@ -256,6 +265,14 @@ class BSBLAN:
256
265
  if param_id in param_filter
257
266
  }
258
267
 
268
+ # Apply include filter if specified - only validate requested params
269
+ if include is not None:
270
+ group_params = {
271
+ param_id: name
272
+ for param_id, name in group_params.items()
273
+ if name in include
274
+ }
275
+
259
276
  if not group_params:
260
277
  logger.debug("No parameters to validate for group %s", group_name)
261
278
  self._validated_hot_water_groups.add(group_name)
@@ -337,12 +354,14 @@ class BSBLAN:
337
354
  self._extract_temperature_unit_from_response(response_data)
338
355
 
339
356
  async def _validate_api_section(
340
- self, section: SectionLiteral
357
+ self, section: SectionLiteral, include: list[str] | None = None
341
358
  ) -> dict[str, Any] | None:
342
359
  """Validate a specific section of the API configuration.
343
360
 
344
361
  Args:
345
362
  section: The section name to validate
363
+ include: Optional list of parameter names to validate. If None,
364
+ validates all parameters for the section.
346
365
 
347
366
  Returns:
348
367
  dict[str, Any] | None: The response data from the device, or None if
@@ -371,6 +390,14 @@ class BSBLAN:
371
390
  error_msg = f"Section '{section}' not found in API data"
372
391
  raise BSBLANError(error_msg) from err
373
392
 
393
+ # Filter to only included params if specified
394
+ if include is not None:
395
+ section_data = {
396
+ param_id: name
397
+ for param_id, name in section_data.items()
398
+ if name in include
399
+ }
400
+
374
401
  try:
375
402
  # Request data from device for validation
376
403
  params = await self._extract_params_summary(section_data)
@@ -379,7 +406,7 @@ class BSBLAN:
379
406
  )
380
407
 
381
408
  # Validate the section against actual device response
382
- api_validator.validate_section(section, response_data)
409
+ api_validator.validate_section(section, response_data, include)
383
410
  # Update API data with validated configuration
384
411
  if self._api_data:
385
412
  self._api_data[section] = api_validator.get_section_params(section)
@@ -794,8 +821,8 @@ class BSBLAN:
794
821
  are valid for this section.
795
822
 
796
823
  """
797
- # Lazy load: validate section on first access
798
- await self._ensure_section_validated(section)
824
+ # Lazy load: validate section on first access (only for included params)
825
+ await self._ensure_section_validated(section, include)
799
826
 
800
827
  section_params = self._api_validator.get_section_params(section)
801
828
 
@@ -1091,7 +1118,8 @@ class BSBLAN:
1091
1118
 
1092
1119
  """
1093
1120
  # Granular lazy load: validate only this param group on first access
1094
- await self._ensure_hot_water_group_validated(group_name, param_filter)
1121
+ # Pass include filter so we only validate requested params
1122
+ await self._ensure_hot_water_group_validated(group_name, param_filter, include)
1095
1123
 
1096
1124
  # Use cached validated params
1097
1125
  filtered_params = {
@@ -77,13 +77,20 @@ class APIValidator:
77
77
  api_config: Any # intentionally permissive to support tests and dynamic data
78
78
  validated_sections: set[str] = field(default_factory=set)
79
79
 
80
- def validate_section(self, section: str, request_data: dict[str, Any]) -> None:
80
+ def validate_section(
81
+ self,
82
+ section: str,
83
+ request_data: dict[str, Any],
84
+ include: list[str] | None = None,
85
+ ) -> None:
81
86
  """Validate and update a section of API config based on actual device support.
82
87
 
83
88
  Args:
84
89
  section: The section of the API config to validate
85
90
  (e.g., 'heating', 'hot_water')
86
91
  request_data: Response data from the device for validation
92
+ include: Optional list of parameter names to validate. If None,
93
+ validates all parameters for the section.
87
94
 
88
95
  """
89
96
  # Check if the section exists in the APIConfig object
@@ -99,8 +106,12 @@ class APIValidator:
99
106
  section_config = self.api_config[section]
100
107
  params_to_remove = []
101
108
 
102
- # Check each parameter in the section
109
+ # Check each parameter in the section (filtered by include if specified)
103
110
  for param_id, param_name in section_config.items():
111
+ # Skip params not in include list (if include is specified)
112
+ if include is not None and param_name not in include:
113
+ continue
114
+
104
115
  if param_id not in request_data:
105
116
  logger.info(
106
117
  "Parameter %s (%s) not found in device response",
@@ -163,7 +163,10 @@ async def test_validate_api_section_validation_error(
163
163
  bsblan._api_validator = APIValidator(bsblan._api_data)
164
164
 
165
165
  def mock_validate(
166
- _self: APIValidator, _section: str, _response: dict[str, Any]
166
+ _self: APIValidator,
167
+ _section: str,
168
+ _response: dict[str, Any],
169
+ _include: list[str] | None = None,
167
170
  ) -> NoReturn:
168
171
  error_message = "Validation error"
169
172
  raise BSBLANError(error_message)
@@ -296,7 +299,9 @@ async def test_ensure_section_validated_double_check_after_lock() -> None:
296
299
  # Track validation calls
297
300
  validation_count = 0
298
301
 
299
- async def mock_validate(section: str) -> dict[str, Any]:
302
+ async def mock_validate(
303
+ section: str, _include: list[str] | None = None
304
+ ) -> dict[str, Any]:
300
305
  nonlocal validation_count
301
306
  validation_count += 1
302
307
  # Mark section as validated
@@ -329,7 +334,9 @@ async def test_ensure_section_validated_concurrent_double_check() -> None:
329
334
  validation_count = 0
330
335
  validation_started = asyncio.Event()
331
336
 
332
- async def slow_validate(section: str) -> dict[str, Any]:
337
+ async def slow_validate(
338
+ section: str, _include: list[str] | None = None
339
+ ) -> dict[str, Any]:
333
340
  nonlocal validation_count
334
341
  validation_count += 1
335
342
  validation_started.set()
@@ -392,7 +399,9 @@ async def test_ensure_section_validated_heating_extracts_temp_unit() -> None:
392
399
  bsblan._api_validator = APIValidator(bsblan._api_data)
393
400
 
394
401
  # Mock _validate_api_section to return response with temp unit
395
- async def mock_validate(section: str) -> dict[str, Any]:
402
+ async def mock_validate(
403
+ section: str, _include: list[str] | None = None
404
+ ) -> dict[str, Any]:
396
405
  bsblan._api_validator.validated_sections.add(section)
397
406
  if section == "heating":
398
407
  return {"710": {"value": "20.0", "unit": "°F"}}
@@ -571,3 +571,138 @@ async def test_ensure_hot_water_group_concurrent_double_check() -> None:
571
571
 
572
572
  # Only one request should have been made
573
573
  assert request_count == 1
574
+
575
+
576
+ @pytest.mark.asyncio
577
+ async def test_ensure_hot_water_group_validated_with_include_filter() -> None:
578
+ """Test that include filter limits which params are validated."""
579
+ async with aiohttp.ClientSession() as session:
580
+ bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
581
+ bsblan._api_version = "v3"
582
+ # Set up api_data with multiple params in the config group
583
+ bsblan._api_data = { # type: ignore[assignment]
584
+ "hot_water": {
585
+ "1640": "legionella_function",
586
+ "1645": "legionella_function_setpoint",
587
+ "1648": "legionella_circulation_temp_diff",
588
+ }
589
+ }
590
+ bsblan._api_validator = APIValidator(bsblan._api_data)
591
+
592
+ requested_params: list[str] = []
593
+
594
+ async def mock_request(
595
+ params: dict[str, str] | None = None,
596
+ **_kwargs: Any,
597
+ ) -> dict[str, Any]:
598
+ if params:
599
+ requested_params.append(params.get("Parameter", ""))
600
+ # Return valid data for all requested params
601
+ return {
602
+ "1640": {"value": "1", "unit": ""},
603
+ "1645": {"value": "60", "unit": "°C"},
604
+ }
605
+
606
+ bsblan._request = mock_request # type: ignore[method-assign]
607
+
608
+ # Validate with include filter - only request 2 of 3 params
609
+ await bsblan._ensure_hot_water_group_validated(
610
+ "config",
611
+ {"1640", "1645", "1648"},
612
+ include=["legionella_function", "legionella_function_setpoint"],
613
+ )
614
+
615
+ # Verify only filtered params were requested (not 1648)
616
+ assert len(requested_params) == 1
617
+ assert "1648" not in requested_params[0]
618
+ assert "1640" in requested_params[0]
619
+ assert "1645" in requested_params[0]
620
+
621
+ # Cache should only contain the validated params
622
+ assert "1640" in bsblan._hot_water_param_cache
623
+ assert "1645" in bsblan._hot_water_param_cache
624
+ assert "1648" not in bsblan._hot_water_param_cache
625
+
626
+
627
+ @pytest.mark.asyncio
628
+ async def test_ensure_hot_water_group_validated_include_empty_result() -> None:
629
+ """Test that include filter with no matching params marks group validated."""
630
+ async with aiohttp.ClientSession() as session:
631
+ bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
632
+ bsblan._api_version = "v3"
633
+ bsblan._api_data = { # type: ignore[assignment]
634
+ "hot_water": {
635
+ "1640": "legionella_function",
636
+ }
637
+ }
638
+ bsblan._api_validator = APIValidator(bsblan._api_data)
639
+
640
+ request_count = 0
641
+
642
+ async def mock_request(**_kwargs: Any) -> dict[str, Any]:
643
+ nonlocal request_count
644
+ request_count += 1
645
+ return {}
646
+
647
+ bsblan._request = mock_request # type: ignore[method-assign]
648
+
649
+ # Include filter with a param name that doesn't exist in the group
650
+ await bsblan._ensure_hot_water_group_validated(
651
+ "config",
652
+ {"1640"},
653
+ include=["nonexistent_param"],
654
+ )
655
+
656
+ # No request should be made since no params match
657
+ assert request_count == 0
658
+ # Group should still be marked as validated
659
+ assert "config" in bsblan._validated_hot_water_groups
660
+
661
+
662
+ @pytest.mark.asyncio
663
+ async def test_ensure_hot_water_group_validated_without_include() -> None:
664
+ """Test that without include filter all group params are validated."""
665
+ async with aiohttp.ClientSession() as session:
666
+ bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
667
+ bsblan._api_version = "v3"
668
+ bsblan._api_data = { # type: ignore[assignment]
669
+ "hot_water": {
670
+ "1640": "legionella_function",
671
+ "1645": "legionella_function_setpoint",
672
+ "1648": "legionella_circulation_temp_diff",
673
+ }
674
+ }
675
+ bsblan._api_validator = APIValidator(bsblan._api_data)
676
+
677
+ requested_params: list[str] = []
678
+
679
+ async def mock_request(
680
+ params: dict[str, str] | None = None,
681
+ **_kwargs: Any,
682
+ ) -> dict[str, Any]:
683
+ if params:
684
+ requested_params.append(params.get("Parameter", ""))
685
+ return {
686
+ "1640": {"value": "1", "unit": ""},
687
+ "1645": {"value": "60", "unit": "°C"},
688
+ "1648": {"value": "5", "unit": "K"},
689
+ }
690
+
691
+ bsblan._request = mock_request # type: ignore[method-assign]
692
+
693
+ # Validate without include filter - all params should be requested
694
+ await bsblan._ensure_hot_water_group_validated(
695
+ "config",
696
+ {"1640", "1645", "1648"},
697
+ )
698
+
699
+ # All 3 params should be in the request
700
+ assert len(requested_params) == 1
701
+ assert "1640" in requested_params[0]
702
+ assert "1645" in requested_params[0]
703
+ assert "1648" in requested_params[0]
704
+
705
+ # All params should be cached
706
+ assert "1640" in bsblan._hot_water_param_cache
707
+ assert "1645" in bsblan._hot_water_param_cache
708
+ assert "1648" in bsblan._hot_water_param_cache
@@ -164,6 +164,86 @@ async def test_section_method_with_invalid_params(
164
164
  request_mock.assert_not_awaited()
165
165
 
166
166
 
167
+ # ========== Tests for _validate_api_section with include parameter ==========
168
+
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_validate_api_section_with_include_filter(monkeypatch: Any) -> None:
172
+ """Test _validate_api_section filters params when include is specified."""
173
+ async with aiohttp.ClientSession() as session:
174
+ config = BSBLANConfig(host="example.com")
175
+ bsblan = BSBLAN(config, session=session)
176
+
177
+ monkeypatch.setattr(bsblan, "_firmware_version", "1.0.38-20200730234859")
178
+ monkeypatch.setattr(bsblan, "_api_version", "v3")
179
+
180
+ # Set up API data with multiple params
181
+ api_data = {
182
+ "heating": {
183
+ "700": "hvac_mode",
184
+ "710": "target_temperature",
185
+ "8740": "current_temperature",
186
+ },
187
+ "sensor": {},
188
+ "staticValues": {},
189
+ "device": {},
190
+ "hot_water": {},
191
+ }
192
+ bsblan._api_data = api_data # type: ignore[assignment]
193
+
194
+ api_validator = APIValidator(api_data)
195
+ bsblan._api_validator = api_validator
196
+
197
+ # Mock request to return only the filtered param
198
+ request_mock = AsyncMock(
199
+ return_value={"700": {"value": "1", "unit": "", "desc": "Auto"}}
200
+ )
201
+ monkeypatch.setattr(bsblan, "_request", request_mock)
202
+
203
+ # Validate with include filter - should only request hvac_mode
204
+ result = await bsblan._validate_api_section("heating", include=["hvac_mode"])
205
+
206
+ # Verify only filtered param was requested
207
+ request_mock.assert_awaited_once()
208
+ call_args = request_mock.call_args
209
+ assert call_args.kwargs["params"]["Parameter"] == "700"
210
+
211
+ # Result should contain the response data
212
+ assert result is not None
213
+ assert "700" in result
214
+
215
+
216
+ @pytest.mark.asyncio
217
+ async def test_validate_section_skips_params_not_in_include() -> None:
218
+ """Test validate_section skips params not in include list."""
219
+ # Set up API config with multiple params
220
+ api_config = {
221
+ "heating": {
222
+ "700": "hvac_mode",
223
+ "710": "target_temperature",
224
+ "8740": "current_temperature",
225
+ },
226
+ }
227
+
228
+ api_validator = APIValidator(api_config)
229
+
230
+ # Mock request data with only one param (simulating filtered response)
231
+ request_data = {"700": {"value": "1", "unit": "", "desc": "Auto"}}
232
+
233
+ # Validate with include filter - should skip params not in include
234
+ api_validator.validate_section("heating", request_data, include=["hvac_mode"])
235
+
236
+ # Section should be validated
237
+ assert api_validator.is_section_validated("heating")
238
+
239
+ # hvac_mode (700) should still be in config since it was valid
240
+ # Other params should NOT be removed since they weren't in include
241
+ assert "700" in api_validator.api_config["heating"]
242
+ # 710 and 8740 were not validated (skipped), so they remain
243
+ assert "710" in api_validator.api_config["heating"]
244
+ assert "8740" in api_validator.api_config["heating"]
245
+
246
+
167
247
  # ========== Additional state() specific tests ==========
168
248
 
169
249
 
@@ -46,6 +46,9 @@ async def test_info(aresponses: ResponsesMockServer, monkeypatch: Any) -> None:
46
46
 
47
47
  info: Info = await bsblan.info()
48
48
  assert info
49
+ assert info.controller_family is not None
50
+ assert info.controller_variant is not None
51
+ assert info.device_identification is not None
49
52
  assert info.controller_family.value == 211
50
53
  assert info.controller_variant.value == 127
51
54
  assert info.device_identification.value == "RVS21.831F/127"
File without changes
File without changes