From b161ef4234849feca889bfc3b87bd9106fde1d0b Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 2 Mar 2026 13:50:24 +0100 Subject: [PATCH 1/4] Enhance circuit availability detection with secondary status checks --- src/bsblan/bsblan.py | 52 ++++++++++++++++++++++++++++++++++++----- src/bsblan/constants.py | 11 +++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index ac59b187..dd864e5c 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -25,6 +25,7 @@ CIRCUIT_HEATING_SECTIONS, CIRCUIT_PROBE_PARAMS, CIRCUIT_STATIC_SECTIONS, + CIRCUIT_STATUS_PARAMS, CIRCUIT_THERMOSTAT_PARAMS, DHW_TIME_PROGRAM_PARAMS, EMPTY_INCLUDE_LIST_ERROR_MSG, @@ -33,6 +34,7 @@ HOT_WATER_CONFIG_PARAMS, HOT_WATER_ESSENTIAL_PARAMS, HOT_WATER_SCHEDULE_PARAMS, + INACTIVE_CIRCUIT_MARKER, INVALID_CIRCUIT_ERROR_MSG, INVALID_INCLUDE_PARAMS_ERROR_MSG, INVALID_RESPONSE_ERROR_MSG, @@ -182,9 +184,13 @@ async def initialize(self) -> None: async def get_available_circuits(self) -> list[int]: """Detect which heating circuits are available on the device. - Probes the operating mode parameter for each circuit (1, 2, 3). - A circuit is considered available if the device returns a non-empty - response with a valid value (not empty ``{}``). + Uses a two-step probe for each circuit (1, 2, 3): + 1. Query the operating mode parameter — the response must be + non-empty and contain actual data. + 2. Query the status parameter (8000/8001/8002) — an inactive + circuit returns ``value="0"`` with ``desc="---"``. + + A circuit is only considered available when both checks pass. This is useful for integration setup flows (e.g., Home Assistant config flow) to discover how many circuits the user's controller @@ -207,10 +213,44 @@ async def get_available_circuits(self) -> list[int]: ) # A circuit exists if the response contains the param_id key # with actual data (not an empty dict) - if response.get(param_id): - available.append(circuit) + if not response.get(param_id): + continue + + # Secondary check: query the status parameter. + # Inactive circuits either: + # - return value="0" and desc="---" + # - return an empty dict {} (param not supported) + status_id = CIRCUIT_STATUS_PARAMS[circuit] + status_resp = await self._request( + params={"Parameter": status_id}, + ) + status_data = status_resp.get(status_id, {}) + + # Empty response means the parameter doesn't exist + if not status_data or not isinstance(status_data, dict): + logger.debug( + "Circuit %d has no status data (not supported)", + circuit, + ) + continue + + # value="0" + desc="---" means inactive + if ( + status_data.get("desc") == INACTIVE_CIRCUIT_MARKER + and str(status_data.get("value", "")) == "0" + ): + logger.debug( + "Circuit %d has status '---' (inactive)", + circuit, + ) + continue + + available.append(circuit) except BSBLANError: - logger.debug("Circuit %d not available (request failed)", circuit) + logger.debug( + "Circuit %d not available (request failed)", + circuit, + ) return sorted(available) async def _setup_api_validator(self) -> None: diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index c8e8ffcd..8f7d9136 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -187,6 +187,17 @@ class APIConfig(TypedDict): 3: "1300", } +# Status parameter IDs used as a secondary check for circuit availability. +# Inactive circuits return value="0" and desc="---" for these parameters. +CIRCUIT_STATUS_PARAMS: Final[dict[int, str]] = { + 1: "8000", + 2: "8001", + 3: "8002", +} + +# Marker value returned by BSB-LAN for parameters on inactive circuits +INACTIVE_CIRCUIT_MARKER: Final[str] = "---" + def build_api_config(version: str) -> APIConfig: """Build API configuration dynamically based on version. From 6c8ccf9df238f87ead3230b2dfa0f43c6721121c Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 2 Mar 2026 13:50:34 +0100 Subject: [PATCH 2/4] Enhance circuit detection tests for heating circuits with status checks --- tests/test_circuit.py | 175 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 171 insertions(+), 4 deletions(-) diff --git a/tests/test_circuit.py b/tests/test_circuit.py index 6cf55d38..36ff6c50 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -596,12 +596,27 @@ async def mock_request( ) -> dict[str, Any]: params = kwargs.get("params", {}) param_id = params.get("Parameter", "") + # HC1 operating mode if param_id == "700": return {"700": {"value": "1", "unit": "", "desc": "Automatic"}} + # HC1 status - active + if param_id == "8000": + return { + "8000": { + "value": "114", + "desc": "Heating mode Comfort", + } + } + # HC2 operating mode if param_id == "1000": return {"1000": {"value": "1", "unit": "", "desc": "Automatic"}} - # HC3 returns empty - return {"1300": {}} + # HC2 status - active + if param_id == "8001": + return {"8001": {"value": "114", "desc": "Heating mode Comfort"}} + # HC3 operating mode - returns empty (not available) + if param_id == "1300": + return {"1300": {}} + return {} bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] @@ -616,12 +631,24 @@ async def test_get_available_circuits_all_three( """Test detecting all three available heating circuits.""" bsblan = mock_bsblan_circuit + status_map = { + "8000": {"value": "114", "desc": "Heating mode Comfort"}, + "8001": {"value": "140", "desc": "Heating Reduced"}, + "8002": {"value": "114", "desc": "Heating mode Comfort"}, + } + async def mock_request( **kwargs: Any, ) -> dict[str, Any]: params = kwargs.get("params", {}) param_id = params.get("Parameter", "") - return {param_id: {"value": "1", "unit": "", "desc": "Automatic"}} + # Operating mode params + if param_id in {"700", "1000", "1300"}: + return {param_id: {"value": "1", "unit": "", "desc": "Automatic"}} + # Status params - all active + if param_id in status_map: + return {param_id: status_map[param_id]} + return {} bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] @@ -643,7 +670,99 @@ async def mock_request( param_id = params.get("Parameter", "") if param_id == "700": return {"700": {"value": "3", "unit": "", "desc": "Comfort"}} - return {param_id: {}} + if param_id == "8000": + return { + "8000": { + "value": "114", + "desc": "Heating mode Comfort", + } + } + # HC2 and HC3 operating mode - return empty + if param_id in {"1000", "1300"}: + return {param_id: {}} + return {} + + bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] + + circuits = await bsblan.get_available_circuits() + assert circuits == [1] + + +@pytest.mark.asyncio +async def test_get_available_circuits_inactive_by_status( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test that circuits with status '---' are detected as inactive. + + This is the real-world scenario: the device returns a valid operating + mode for all 3 circuits, but status param shows '---' for HC2/HC3. + """ + bsblan = mock_bsblan_circuit + + async def mock_request( + **kwargs: Any, + ) -> dict[str, Any]: + params = kwargs.get("params", {}) + param_id = params.get("Parameter", "") + # All circuits return valid operating mode + if param_id in {"700", "1000", "1300"}: + return {param_id: {"value": "1", "unit": "", "desc": "Automatic"}} + # HC1 status - active + if param_id == "8000": + return { + "8000": { + "value": "114", + "desc": "Heating mode Comfort", + } + } + # HC2 status - inactive (value=0, desc=---) + if param_id == "8001": + return {"8001": {"value": "0", "desc": "---"}} + # HC3 status - inactive (value=0, desc=---) + if param_id == "8002": + return {"8002": {"value": "0", "desc": "---"}} + return {} + + bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] + + circuits = await bsblan.get_available_circuits() + assert circuits == [1] + + +@pytest.mark.asyncio +async def test_get_available_circuits_inactive_empty_status( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test that circuits with empty status response are inactive. + + Some controllers return an empty dict for status params of circuits + that don't exist (e.g., HC3 status 8002 returns {}). + """ + bsblan = mock_bsblan_circuit + + async def mock_request( + **kwargs: Any, + ) -> dict[str, Any]: + params = kwargs.get("params", {}) + param_id = params.get("Parameter", "") + # All circuits return valid operating mode + if param_id in {"700", "1000", "1300"}: + return {param_id: {"value": "1", "unit": "", "desc": "Automatic"}} + # HC1 status - active + if param_id == "8000": + return { + "8000": { + "value": "114", + "desc": "Heating mode Comfort", + } + } + # HC2 status - inactive (value=0, desc=---) + if param_id == "8001": + return {"8001": {"value": "0", "desc": "---"}} + # HC3 status - empty response (param not supported) + if param_id == "8002": + return {} + return {} bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] @@ -669,6 +788,13 @@ async def mock_request( param_id = params.get("Parameter", "") if param_id == "700": return {"700": {"value": "1", "unit": "", "desc": "Automatic"}} + if param_id == "8000": + return { + "8000": { + "value": "114", + "desc": "Heating mode Comfort", + } + } # HC2 and HC3 fail with connection error msg = "Connection failed" raise BSBLANError(msg) @@ -693,6 +819,13 @@ async def mock_request( param_id = params.get("Parameter", "") if param_id == "700": return {"700": {"value": "1", "unit": "", "desc": "Automatic"}} + if param_id == "8000": + return { + "8000": { + "value": "114", + "desc": "Heating mode Comfort", + } + } # Returns a response but without the expected param key return {"other_key": {"value": "1"}} @@ -702,6 +835,40 @@ async def mock_request( assert circuits == [1] +@pytest.mark.asyncio +async def test_get_available_circuits_status_failure_still_detects( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test that a circuit is still detected if status request fails. + + If the operating mode returns valid data but the status request fails, + the circuit should be excluded (fail-safe). + """ + bsblan = mock_bsblan_circuit + + async def mock_request( + **kwargs: Any, + ) -> dict[str, Any]: + params = kwargs.get("params", {}) + param_id = params.get("Parameter", "") + if param_id == "700": + return {"700": {"value": "1", "unit": "", "desc": "Automatic"}} + # Status request fails + if param_id == "8000": + msg = "Connection failed" + raise BSBLANError(msg) + # HC2/HC3 return empty + if param_id in {"1000", "1300"}: + return {param_id: {}} + return {} + + bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] + + # HC1 status request fails -> entire circuit probe fails -> excluded + circuits = await bsblan.get_available_circuits() + assert circuits == [] + + @pytest.mark.asyncio async def test_state_empty_section_after_validation( mock_bsblan_circuit: BSBLAN, From 4cfc4533b03c1f0b7f855bf20ec3e74047da539b Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 2 Mar 2026 13:50:43 +0100 Subject: [PATCH 3/4] Refactor speed test script to enhance benchmark suite structure and add dual/triple circuit comparisons --- examples/speed_test.py | 740 +++++++++++++++++++++++++++++++---------- 1 file changed, 567 insertions(+), 173 deletions(-) diff --git a/examples/speed_test.py b/examples/speed_test.py index 3db2b970..e76eea91 100644 --- a/examples/speed_test.py +++ b/examples/speed_test.py @@ -1,9 +1,11 @@ """Test speed comparison for BSB-LAN API calls. -Compares different approaches: -1. Multiple parallel calls (current approach) -2. Combined read_parameters call -3. With/without parameter filtering +Compares different approaches using pluggable benchmark suites: +- basic: Original tests (parallel calls, read_parameters, filtering) +- scalability: Large parameter set tests +- dual-circuit: Single vs parallel calls for dual heating circuit params +- triple-circuit: Same idea extended to 3 heating circuits +- hot-water: Hot water parameter group loading tests Usage: # Set environment variables (optional - will use mDNS discovery if not set) @@ -11,16 +13,27 @@ export BSBLAN_PORT=80 export BSBLAN_PASSKEY=your_passkey # if needed - # Run the test + # Run all suites python examples/speed_test.py + + # Run specific suite(s) + python examples/speed_test.py --suite dual-circuit + python examples/speed_test.py --suite basic scalability + + # Adjust run counts + python examples/speed_test.py --runs 20 --warmup 5 + + # List available suites + python examples/speed_test.py --list-suites """ from __future__ import annotations +import argparse import asyncio import statistics import time -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING from bsblan import BSBLAN, BSBLANConfig @@ -29,23 +42,47 @@ if TYPE_CHECKING: from collections.abc import Awaitable, Callable -# Test configuration -NUM_WARMUP_RUNS = 2 -NUM_TEST_RUNS = 10 +# Default test configuration +DEFAULT_WARMUP_RUNS = 2 +DEFAULT_TEST_RUNS = 10 -# Parameters used in different tests +# --------------------------------------------------------------------------- +# Parameter sets +# --------------------------------------------------------------------------- + +# Info / device INFO_PARAMS = ["6224"] # Current firmware version STATIC_PARAMS = ["714", "716"] # Min/max temp setpoints ALL_PARAMS = INFO_PARAMS + STATIC_PARAMS -# Large parameter sets for scalability testing -# Heating parameters -HEATING_PARAMS = ["700", "710", "900", "8000", "8740", "8749"] +# Heating circuit 1 (700-series) +HC1_PARAMS = ["700", "710", "900", "8000", "8740", "8749"] + +# Heating circuit 2 (1000-series) — mirrors HC1 with offset +HC2_PARAMS = ["1000", "1010", "1200", "8001", "8741", "8750"] + +# Heating circuit 3 (1300-series) — mirrors HC1 with offset +HC3_PARAMS = ["1300", "1310", "1500", "8002", "8742", "8751"] + +# Static values per circuit +HC1_STATIC_PARAMS = ["714", "716"] +HC2_STATIC_PARAMS = ["1014", "1016"] +HC3_STATIC_PARAMS = ["1314", "1316"] + +# Combined dual circuit parameter sets +DUAL_HEATING_PARAMS = HC1_PARAMS + HC2_PARAMS +DUAL_STATIC_PARAMS = HC1_STATIC_PARAMS + HC2_STATIC_PARAMS +DUAL_ALL_PARAMS = DUAL_HEATING_PARAMS + DUAL_STATIC_PARAMS + +# Triple circuit parameter sets +TRIPLE_HEATING_PARAMS = HC1_PARAMS + HC2_PARAMS + HC3_PARAMS +TRIPLE_STATIC_PARAMS = HC1_STATIC_PARAMS + HC2_STATIC_PARAMS + HC3_STATIC_PARAMS +TRIPLE_ALL_PARAMS = TRIPLE_HEATING_PARAMS + TRIPLE_STATIC_PARAMS # Sensor parameters SENSOR_PARAMS = ["8700", "8740"] -# Hot water parameters (a good mix of config and state) +# Hot water parameters HOT_WATER_PARAMS = [ "1600", # operating_mode "1601", # eco_mode_selection @@ -63,11 +100,16 @@ "8820", # state_dhw_pump ] -# Combined large set (~20 params) -LARGE_PARAM_SET = HEATING_PARAMS + SENSOR_PARAMS + HOT_WATER_PARAMS[:8] # ~16 params +# Combined large set (~16 params) +LARGE_PARAM_SET = HC1_PARAMS + SENSOR_PARAMS + HOT_WATER_PARAMS[:8] # Extra large set (~22 params) -XLARGE_PARAM_SET = HEATING_PARAMS + SENSOR_PARAMS + HOT_WATER_PARAMS # ~22 params +XLARGE_PARAM_SET = HC1_PARAMS + SENSOR_PARAMS + HOT_WATER_PARAMS + + +# --------------------------------------------------------------------------- +# Benchmark infrastructure +# --------------------------------------------------------------------------- @dataclass @@ -76,6 +118,7 @@ class BenchmarkResult: name: str times: list[float] + param_count: int = 0 @property def avg(self) -> float: @@ -104,18 +147,57 @@ def stdev(self) -> float: def __str__(self) -> str: """Format results as string.""" + extra = f" [{self.param_count} params]" if self.param_count else "" return ( - f"{self.name}:\n" + f"{self.name}{extra}:\n" f" avg={self.avg:.3f}s, median={self.median:.3f}s, " - f"min={self.min:.3f}s, max={self.max:.3f}s, stdev={self.stdev:.3f}s" + f"min={self.min:.3f}s, max={self.max:.3f}s, " + f"stdev={self.stdev:.3f}s" + ) + + +@dataclass +class BenchmarkCase: + """A single benchmark test case within a suite.""" + + description: str + short_name: str + fn: Callable[[], Awaitable[object]] + param_count: int = 0 + + +@dataclass +class BenchmarkSuite: + """A group of related benchmark test cases.""" + + name: str + description: str + cases: list[BenchmarkCase] = field(default_factory=list) + + def add( + self, + description: str, + short_name: str, + fn: Callable[[], Awaitable[object]], + param_count: int = 0, + ) -> None: + """Add a benchmark case to this suite.""" + self.cases.append( + BenchmarkCase( + description=description, + short_name=short_name, + fn=fn, + param_count=param_count, + ) ) async def run_test( name: str, test_fn: Callable[[], Awaitable[object]], - num_runs: int = NUM_TEST_RUNS, - warmup_runs: int = NUM_WARMUP_RUNS, + num_runs: int = DEFAULT_TEST_RUNS, + warmup_runs: int = DEFAULT_WARMUP_RUNS, + param_count: int = 0, ) -> BenchmarkResult: """Run a test function multiple times and collect timing stats. @@ -124,6 +206,7 @@ async def run_test( test_fn: Async function to test. num_runs: Number of timed test runs. warmup_runs: Number of warmup runs (not timed). + param_count: Number of parameters involved (for display). Returns: BenchmarkResult with timing statistics. @@ -142,204 +225,498 @@ async def run_test( times.append(elapsed) print(f" Run {i + 1}/{num_runs}: {elapsed:.3f}s") - return BenchmarkResult(name=name, times=times) + return BenchmarkResult(name=name, times=times, param_count=param_count) -async def bench_3_parallel_calls(bsblan: BSBLAN) -> None: - """Benchmark 3 parallel calls (current initialization approach).""" - await asyncio.gather( - bsblan.device(), - bsblan.info(), - bsblan.static_values(), - ) +async def run_suite( + suite: BenchmarkSuite, + num_runs: int = DEFAULT_TEST_RUNS, + warmup_runs: int = DEFAULT_WARMUP_RUNS, +) -> list[BenchmarkResult]: + """Run all benchmark cases in a suite. + Args: + suite: The benchmark suite to run. + num_runs: Number of timed test runs per case. + warmup_runs: Number of warmup runs per case. + + Returns: + List of BenchmarkResult for each case. + + """ + print("\n" + "=" * 60) + print(f"SUITE: {suite.name}") + print(f" {suite.description}") + print("=" * 60) + + results: list[BenchmarkResult] = [] + for i, case in enumerate(suite.cases, 1): + print(f"\nTest {i}: {case.description}") + result = await run_test( + name=case.short_name, + test_fn=case.fn, + num_runs=num_runs, + warmup_runs=warmup_runs, + param_count=case.param_count, + ) + results.append(result) + + return results -async def bench_2_parallel_calls(bsblan: BSBLAN) -> None: - """Benchmark 2 calls: device + combined read_parameters.""" - await asyncio.gather( - bsblan.device(), - bsblan.read_parameters(ALL_PARAMS), - ) +def print_suite_results( + suite_name: str, + results: list[BenchmarkResult], +) -> None: + """Print benchmark results for a suite with comparison table.""" + print("\n" + "=" * 60) + print(f"RESULTS: {suite_name}") + print("=" * 60) -async def bench_1_read_params(bsblan: BSBLAN) -> None: - """Benchmark single read_parameters call with all params.""" - await bsblan.read_parameters(ALL_PARAMS) + for r in results: + print(f"\n{r}") + if len(results) < 2: + return -async def bench_static_values_filtered(bsblan: BSBLAN) -> None: - """Benchmark static_values with include filter.""" - await bsblan.static_values(include=["min_temp"]) + # Compare against first result as baseline + baseline = results[0] + print("\n" + "-" * 60) + print(f"COMPARISON (vs baseline: {baseline.name})") + print("-" * 60) + + for r in results[1:]: + diff = baseline.avg - r.avg + pct = (diff / baseline.avg) * 100 if baseline.avg > 0 else 0 + faster_slower = "faster" if diff > 0 else "slower" + print(f"{r.name}: {abs(diff):.3f}s {faster_slower} ({abs(pct):.1f}%)") + best = min(results, key=lambda x: x.avg) + print(f"\n✓ Best approach: {best.name} (avg: {best.avg:.3f}s)") -async def bench_info_filtered(bsblan: BSBLAN) -> None: - """Benchmark info() with include filter.""" - await bsblan.info(include=["device_identification"]) +# --------------------------------------------------------------------------- +# Suite builders — each returns a BenchmarkSuite +# --------------------------------------------------------------------------- -async def bench_large_params_single_call(bsblan: BSBLAN) -> None: - """Benchmark single call with ~16 parameters.""" - await bsblan.read_parameters(LARGE_PARAM_SET) +def build_basic_suite(bsblan: BSBLAN) -> BenchmarkSuite: + """Build the basic benchmark suite (original tests).""" + suite = BenchmarkSuite( + name="Basic", + description=( + "Original API call patterns: parallel calls, read_parameters, filtering" + ), + ) -async def bench_xlarge_params_single_call(bsblan: BSBLAN) -> None: - """Benchmark single call with ~22 parameters.""" - await bsblan.read_parameters(XLARGE_PARAM_SET) + suite.add( + "3 parallel calls (device + info + static_values)", + "3 parallel calls", + lambda: asyncio.gather( + bsblan.device(), + bsblan.info(), + bsblan.static_values(), + ), + ) + suite.add( + "2 parallel calls (device + read_parameters)", + "2 parallel calls", + lambda: asyncio.gather( + bsblan.device(), + bsblan.read_parameters(ALL_PARAMS), + ), + param_count=len(ALL_PARAMS), + ) + suite.add( + "Single read_parameters call", + "1 read_parameters", + lambda: bsblan.read_parameters(ALL_PARAMS), + param_count=len(ALL_PARAMS), + ) + suite.add( + "static_values with include filter (min_temp only)", + "static_values (filtered)", + lambda: bsblan.static_values(include=["min_temp"]), + param_count=1, + ) + suite.add( + "info with include filter (device_identification only)", + "info (filtered)", + lambda: bsblan.info(include=["device_identification"]), + param_count=1, + ) + suite.add( + "static_values without filter (all params)", + "static_values (all)", + bsblan.static_values, + ) + return suite -async def bench_large_params_4_parallel_calls(bsblan: BSBLAN) -> None: - """Benchmark 4 parallel calls, each with ~4 parameters.""" - chunk_size = len(LARGE_PARAM_SET) // 4 - chunks = [ - LARGE_PARAM_SET[i : i + chunk_size] - for i in range(0, len(LARGE_PARAM_SET), chunk_size) - ] - await asyncio.gather(*[bsblan.read_parameters(chunk) for chunk in chunks]) +def build_scalability_suite(bsblan: BSBLAN) -> BenchmarkSuite: + """Build the scalability benchmark suite (many parameters).""" + suite = BenchmarkSuite( + name="Scalability", + description="Testing with increasingly large parameter sets", + ) -async def bench_xlarge_params_4_parallel_calls(bsblan: BSBLAN) -> None: - """Benchmark 4 parallel calls splitting ~22 parameters.""" - chunk_size = len(XLARGE_PARAM_SET) // 4 - chunks = [ - XLARGE_PARAM_SET[i : i + chunk_size] - for i in range(0, len(XLARGE_PARAM_SET), chunk_size) - ] - await asyncio.gather(*[bsblan.read_parameters(chunk) for chunk in chunks]) + suite.add( + f"Single call with {len(LARGE_PARAM_SET)} params", + f"1 call ({len(LARGE_PARAM_SET)} params)", + lambda: bsblan.read_parameters(LARGE_PARAM_SET), + param_count=len(LARGE_PARAM_SET), + ) + def _large_4_parallel() -> Awaitable[object]: + chunk_size = max(1, len(LARGE_PARAM_SET) // 4) + chunks = [ + LARGE_PARAM_SET[i : i + chunk_size] + for i in range(0, len(LARGE_PARAM_SET), chunk_size) + ] + return asyncio.gather(*[bsblan.read_parameters(c) for c in chunks]) + + suite.add( + f"4 parallel calls ({len(LARGE_PARAM_SET)} params split)", + f"4 calls ({len(LARGE_PARAM_SET)} params)", + _large_4_parallel, + param_count=len(LARGE_PARAM_SET), + ) -async def bench_xlarge_params_2_parallel_calls(bsblan: BSBLAN) -> None: - """Benchmark 2 parallel calls splitting ~22 parameters.""" - mid = len(XLARGE_PARAM_SET) // 2 - await asyncio.gather( - bsblan.read_parameters(XLARGE_PARAM_SET[:mid]), - bsblan.read_parameters(XLARGE_PARAM_SET[mid:]), + suite.add( + f"Single call with {len(XLARGE_PARAM_SET)} params", + f"1 call ({len(XLARGE_PARAM_SET)} params)", + lambda: bsblan.read_parameters(XLARGE_PARAM_SET), + param_count=len(XLARGE_PARAM_SET), ) + def _xlarge_2_parallel() -> Awaitable[object]: + mid = len(XLARGE_PARAM_SET) // 2 + return asyncio.gather( + bsblan.read_parameters(XLARGE_PARAM_SET[:mid]), + bsblan.read_parameters(XLARGE_PARAM_SET[mid:]), + ) + + suite.add( + f"2 parallel calls ({len(XLARGE_PARAM_SET)} params split)", + f"2 calls ({len(XLARGE_PARAM_SET)} params)", + _xlarge_2_parallel, + param_count=len(XLARGE_PARAM_SET), + ) -async def bench_info_only(bsblan: BSBLAN) -> None: - """Benchmark info() call only.""" - await bsblan.info() + def _xlarge_4_parallel() -> Awaitable[object]: + chunk_size = max(1, len(XLARGE_PARAM_SET) // 4) + chunks = [ + XLARGE_PARAM_SET[i : i + chunk_size] + for i in range(0, len(XLARGE_PARAM_SET), chunk_size) + ] + return asyncio.gather(*[bsblan.read_parameters(c) for c in chunks]) + + suite.add( + f"4 parallel calls ({len(XLARGE_PARAM_SET)} params split)", + f"4 calls ({len(XLARGE_PARAM_SET)} params)", + _xlarge_4_parallel, + param_count=len(XLARGE_PARAM_SET), + ) + return suite -async def bench_static_values_only(bsblan: BSBLAN) -> None: - """Benchmark static_values() call only.""" - await bsblan.static_values() +def build_dual_circuit_suite(bsblan: BSBLAN) -> BenchmarkSuite: + """Build the dual heating circuit benchmark suite. -async def run_all_benchmarks(bsblan: BSBLAN) -> list[BenchmarkResult]: - """Run all speed benchmarks and return results.""" - results: list[BenchmarkResult] = [] + Compares strategies for fetching parameters from two circuits: + - 1 combined call with all HC1 + HC2 params + - 2 parallel calls (one per circuit) + - 2 sequential calls (one per circuit) - # Define basic tests - basic_tests = [ - ( - "Test 1: 3 parallel calls (device + info + static_values)", - "3 parallel calls", - lambda: bench_3_parallel_calls(bsblan), - ), - ( - "Test 2: 2 parallel calls (device + read_parameters)", - "2 parallel calls", - lambda: bench_2_parallel_calls(bsblan), - ), - ( - "Test 3: Single read_parameters call", - "1 read_parameters", - lambda: bench_1_read_params(bsblan), + NOTE: On a single-circuit system, HC2 parameters will return + '---' or default values, but the API call overhead is the same + — so this still measures real network timing accurately. + """ + suite = BenchmarkSuite( + name="Dual Heating Circuit", + description=( + "Compare fetching strategies for 2 heating circuits.\n" + " HC1 params: " + ", ".join(HC1_PARAMS) + "\n" + " HC2 params: " + ", ".join(HC2_PARAMS) ), - ( - "Test 4: static_values with include filter (min_temp only)", - "static_values (filtered)", - lambda: bench_static_values_filtered(bsblan), + ) + + # --- Heating params only (state data, polled frequently) --- + + suite.add( + f"HC1 only — 1 call ({len(HC1_PARAMS)} params)", + f"HC1 only ({len(HC1_PARAMS)}p)", + lambda: bsblan.read_parameters(HC1_PARAMS), + param_count=len(HC1_PARAMS), + ) + + suite.add( + (f"HC1+HC2 combined — 1 call ({len(DUAL_HEATING_PARAMS)} params)"), + f"1 call ({len(DUAL_HEATING_PARAMS)}p)", + lambda: bsblan.read_parameters(DUAL_HEATING_PARAMS), + param_count=len(DUAL_HEATING_PARAMS), + ) + + suite.add( + (f"HC1+HC2 parallel — 2 calls ({len(HC1_PARAMS)}+{len(HC2_PARAMS)} params)"), + f"2 parallel ({len(HC1_PARAMS)}+{len(HC2_PARAMS)}p)", + lambda: asyncio.gather( + bsblan.read_parameters(HC1_PARAMS), + bsblan.read_parameters(HC2_PARAMS), ), - ( - "Test 5: info with include filter (device_identification only)", - "info (filtered)", - lambda: bench_info_filtered(bsblan), + param_count=len(DUAL_HEATING_PARAMS), + ) + + async def _sequential_hc1_hc2() -> None: + await bsblan.read_parameters(HC1_PARAMS) + await bsblan.read_parameters(HC2_PARAMS) + + suite.add( + (f"HC1+HC2 sequential — 2 calls ({len(HC1_PARAMS)}+{len(HC2_PARAMS)} params)"), + f"2 sequential ({len(HC1_PARAMS)}+{len(HC2_PARAMS)}p)", + _sequential_hc1_hc2, + param_count=len(DUAL_HEATING_PARAMS), + ) + + # --- Heating + static params (full init scenario) --- + + suite.add( + (f"HC1+HC2 all (heating+static) — 1 call ({len(DUAL_ALL_PARAMS)} params)"), + f"1 call all ({len(DUAL_ALL_PARAMS)}p)", + lambda: bsblan.read_parameters(DUAL_ALL_PARAMS), + param_count=len(DUAL_ALL_PARAMS), + ) + + suite.add( + "HC1+HC2 all — 3 parallel (heating per circuit + static)", + "3 parallel heat+static", + lambda: asyncio.gather( + bsblan.read_parameters(HC1_PARAMS), + bsblan.read_parameters(HC2_PARAMS), + bsblan.read_parameters(DUAL_STATIC_PARAMS), ), - ( - "Test 6: static_values without filter (all params)", - "static_values (all)", - lambda: bench_static_values_only(bsblan), + param_count=len(DUAL_ALL_PARAMS), + ) + + suite.add( + "HC1+HC2 all — 4 parallel (heat+static per circuit)", + "4 parallel per section", + lambda: asyncio.gather( + bsblan.read_parameters(HC1_PARAMS), + bsblan.read_parameters(HC2_PARAMS), + bsblan.read_parameters(HC1_STATIC_PARAMS), + bsblan.read_parameters(HC2_STATIC_PARAMS), ), - ] + param_count=len(DUAL_ALL_PARAMS), + ) - for desc, name, bench_fn in basic_tests: - print(f"\n{desc}") - result = await run_test(name, bench_fn) - results.append(result) + return suite - # Scalability tests - print("\n" + "=" * 60) - print("SCALABILITY TESTS - Many Parameters") - print("=" * 60) - scalability_tests = [ - ( - f"Test 7: Single call with {len(LARGE_PARAM_SET)} params", - f"1 call ({len(LARGE_PARAM_SET)} params)", - lambda: bench_large_params_single_call(bsblan), +def build_triple_circuit_suite(bsblan: BSBLAN) -> BenchmarkSuite: + """Build the triple heating circuit benchmark suite. + + Same idea as dual-circuit but for 3 circuits. Most systems have + at most 2 circuits; HC3 params will return '---' on those + devices but this still measures the network call overhead. + """ + suite = BenchmarkSuite( + name="Triple Heating Circuit", + description=( + "Compare fetching strategies for 3 heating circuits.\n" + " HC1: " + ", ".join(HC1_PARAMS) + "\n" + " HC2: " + ", ".join(HC2_PARAMS) + "\n" + " HC3: " + ", ".join(HC3_PARAMS) ), - ( - f"Test 8: 4 parallel calls ({len(LARGE_PARAM_SET)} params split)", - f"4 calls ({len(LARGE_PARAM_SET)} params)", - lambda: bench_large_params_4_parallel_calls(bsblan), + ) + + suite.add( + (f"HC1+HC2+HC3 combined — 1 call ({len(TRIPLE_HEATING_PARAMS)} params)"), + f"1 call ({len(TRIPLE_HEATING_PARAMS)}p)", + lambda: bsblan.read_parameters(TRIPLE_HEATING_PARAMS), + param_count=len(TRIPLE_HEATING_PARAMS), + ) + + suite.add( + "HC1+HC2+HC3 parallel — 3 calls", + "3 parallel", + lambda: asyncio.gather( + bsblan.read_parameters(HC1_PARAMS), + bsblan.read_parameters(HC2_PARAMS), + bsblan.read_parameters(HC3_PARAMS), ), - ( - f"Test 9: Single call with {len(XLARGE_PARAM_SET)} params", - f"1 call ({len(XLARGE_PARAM_SET)} params)", - lambda: bench_xlarge_params_single_call(bsblan), + param_count=len(TRIPLE_HEATING_PARAMS), + ) + + async def _sequential_3() -> None: + await bsblan.read_parameters(HC1_PARAMS) + await bsblan.read_parameters(HC2_PARAMS) + await bsblan.read_parameters(HC3_PARAMS) + + suite.add( + "HC1+HC2+HC3 sequential — 3 calls", + "3 sequential", + _sequential_3, + param_count=len(TRIPLE_HEATING_PARAMS), + ) + + # Full init with static values + suite.add( + (f"All circuits + static — 1 call ({len(TRIPLE_ALL_PARAMS)} params)"), + f"1 call all ({len(TRIPLE_ALL_PARAMS)}p)", + lambda: bsblan.read_parameters(TRIPLE_ALL_PARAMS), + param_count=len(TRIPLE_ALL_PARAMS), + ) + + suite.add( + "All circuits + static — 6 parallel (heat+static per circ)", + "6 parallel per section", + lambda: asyncio.gather( + bsblan.read_parameters(HC1_PARAMS), + bsblan.read_parameters(HC2_PARAMS), + bsblan.read_parameters(HC3_PARAMS), + bsblan.read_parameters(HC1_STATIC_PARAMS), + bsblan.read_parameters(HC2_STATIC_PARAMS), + bsblan.read_parameters(HC3_STATIC_PARAMS), ), - ( - f"Test 10: 2 parallel calls ({len(XLARGE_PARAM_SET)} params split)", - f"2 calls ({len(XLARGE_PARAM_SET)} params)", - lambda: bench_xlarge_params_2_parallel_calls(bsblan), + param_count=len(TRIPLE_ALL_PARAMS), + ) + + return suite + + +def build_hot_water_suite(bsblan: BSBLAN) -> BenchmarkSuite: + """Build the hot water parameter benchmark suite.""" + suite = BenchmarkSuite( + name="Hot Water", + description=("Compare fetching strategies for hot water parameters"), + ) + + suite.add( + (f"All hot water params — 1 call ({len(HOT_WATER_PARAMS)} params)"), + f"1 call ({len(HOT_WATER_PARAMS)}p)", + lambda: bsblan.read_parameters(HOT_WATER_PARAMS), + param_count=len(HOT_WATER_PARAMS), + ) + + mid = len(HOT_WATER_PARAMS) // 2 + rest = len(HOT_WATER_PARAMS) - mid + suite.add( + f"Hot water — 2 parallel calls ({mid}+{rest})", + "2 parallel", + lambda: asyncio.gather( + bsblan.read_parameters(HOT_WATER_PARAMS[:mid]), + bsblan.read_parameters(HOT_WATER_PARAMS[mid:]), ), - ( - f"Test 11: 4 parallel calls ({len(XLARGE_PARAM_SET)} params split)", - f"4 calls ({len(XLARGE_PARAM_SET)} params)", - lambda: bench_xlarge_params_4_parallel_calls(bsblan), + param_count=len(HOT_WATER_PARAMS), + ) + + # Combine hot water + dual circuit (realistic HA polling scenario) + combined = DUAL_HEATING_PARAMS + HOT_WATER_PARAMS + suite.add( + (f"Dual circuit + hot water — 1 call ({len(combined)} params)"), + f"1 call combined ({len(combined)}p)", + lambda: bsblan.read_parameters(combined), + param_count=len(combined), + ) + + suite.add( + "Dual circuit + hot water — 3 parallel (HC1, HC2, DHW)", + "3 parallel HC1+HC2+DHW", + lambda: asyncio.gather( + bsblan.read_parameters(HC1_PARAMS), + bsblan.read_parameters(HC2_PARAMS), + bsblan.read_parameters(HOT_WATER_PARAMS), ), - ] + param_count=len(combined), + ) - for desc, name, bench_fn in scalability_tests: - print(f"\n{desc}") - result = await run_test(name, bench_fn) - results.append(result) + return suite - return results +# --------------------------------------------------------------------------- +# Suite registry +# --------------------------------------------------------------------------- -def print_results(results: list[BenchmarkResult]) -> None: - """Print test results summary.""" - print("\n" + "=" * 60) - print("RESULTS SUMMARY") - print("=" * 60) +# Maps suite key -> builder function(bsblan) -> BenchmarkSuite +SUITE_BUILDERS: dict[str, Callable[[BSBLAN], BenchmarkSuite]] = { + "basic": build_basic_suite, + "scalability": build_scalability_suite, + "dual-circuit": build_dual_circuit_suite, + "triple-circuit": build_triple_circuit_suite, + "hot-water": build_hot_water_suite, +} - for r in results: - print(f"\n{r}") - # Compare approaches - baseline = results[0].avg # 3 parallel calls - print("\n" + "-" * 60) - print("COMPARISON (vs 3 parallel calls baseline)") - print("-" * 60) +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- - for r in results[1:]: - diff = baseline - r.avg - pct = (diff / baseline) * 100 if baseline > 0 else 0 - faster_slower = "faster" if diff > 0 else "slower" - print(f"{r.name}: {abs(diff):.3f}s {faster_slower} ({abs(pct):.1f}%)") - # Best approach - best = min(results, key=lambda x: x.avg) - print(f"\n✓ Best approach: {best.name} (avg: {best.avg:.3f}s)") +def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="BSB-LAN API speed comparison benchmarks", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Available suites:\n" + + "\n".join(f" {k}" for k in SUITE_BUILDERS) + + "\n\nExamples:\n" + " python examples/speed_test.py --suite dual-circuit\n" + " python examples/speed_test.py --suite basic scalability\n" + " python examples/speed_test.py --runs 20 --warmup 5\n" + ), + ) + parser.add_argument( + "--suite", + nargs="+", + choices=[*SUITE_BUILDERS, "all"], + default=["all"], + help="Which benchmark suite(s) to run (default: all)", + ) + parser.add_argument( + "--runs", + type=int, + default=DEFAULT_TEST_RUNS, + help=(f"Number of timed test runs (default: {DEFAULT_TEST_RUNS})"), + ) + parser.add_argument( + "--warmup", + type=int, + default=DEFAULT_WARMUP_RUNS, + help=(f"Number of warmup runs (default: {DEFAULT_WARMUP_RUNS})"), + ) + parser.add_argument( + "--list-suites", + action="store_true", + help="List available benchmark suites and exit", + ) + return parser.parse_args() + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- async def main() -> None: - """Run speed comparison tests.""" + """Run speed comparison benchmarks.""" + args = parse_args() + + if args.list_suites: + print("Available benchmark suites:") + for key in SUITE_BUILDERS: + print(f" {key}") + return + + suite_keys: list[str] = ( + list(SUITE_BUILDERS.keys()) if "all" in args.suite else args.suite + ) + print("=" * 60) - print("BSB-LAN API Speed Comparison Test") + print("BSB-LAN API Speed Comparison Benchmark") print("=" * 60) # Get configuration from environment or discovery @@ -353,8 +730,9 @@ async def main() -> None: return print(f"\nConnecting to: {host}:{port}") + print(f"Suites: {', '.join(suite_keys)}") + print(f"Runs: {args.runs} (warmup: {args.warmup})") - # Build config - cast values to str for type safety passkey = env_config.get("passkey") username = env_config.get("username") password = env_config.get("password") @@ -371,11 +749,27 @@ async def main() -> None: await bsblan.initialize() print("✓ BSB-LAN client initialized\n") - print(f"Running {NUM_TEST_RUNS} test runs with {NUM_WARMUP_RUNS} warmup runs\n") - print("-" * 60) - - results = await run_all_benchmarks(bsblan) - print_results(results) + all_results: dict[str, list[BenchmarkResult]] = {} + + for key in suite_keys: + builder = SUITE_BUILDERS[key] + suite = builder(bsblan) + results = await run_suite( + suite, + num_runs=args.runs, + warmup_runs=args.warmup, + ) + all_results[suite.name] = results + print_suite_results(suite.name, results) + + # Print overall summary if multiple suites ran + if len(all_results) > 1: + print("\n" + "=" * 60) + print("OVERALL SUMMARY") + print("=" * 60) + for suite_name, results in all_results.items(): + best = min(results, key=lambda x: x.avg) + print(f" {suite_name}: best = {best.name} ({best.avg:.3f}s)") if __name__ == "__main__": From 1839ab05814fa2c0108a49743ef5cee3dedfdc43 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 2 Mar 2026 13:58:37 +0100 Subject: [PATCH 4/4] cleanup docstring etc --- README.md | 2 +- examples/fetch_param.py | 2 +- examples/speed_test.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1ba3fc9e..48a0fe91 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ async def main() -> None: """ # Create a configuration object config = BSBLANConfig( - host="10.0.2.60", + host="192.0.2.1", passkey=None, username=os.getenv("USERNAME"), # Compliant password=os.getenv("PASSWORD"), # Compliant diff --git a/examples/fetch_param.py b/examples/fetch_param.py index 4053822d..d4eb82dd 100644 --- a/examples/fetch_param.py +++ b/examples/fetch_param.py @@ -1,7 +1,7 @@ """Fetch one or more BSB-LAN parameters and print the raw API response. Usage: - export BSBLAN_HOST=10.0.2.60 + export BSBLAN_HOST=192.0.2.1 export BSBLAN_PASSKEY=your_passkey # if needed # Single parameter diff --git a/examples/speed_test.py b/examples/speed_test.py index e76eea91..963ede7d 100644 --- a/examples/speed_test.py +++ b/examples/speed_test.py @@ -9,7 +9,7 @@ Usage: # Set environment variables (optional - will use mDNS discovery if not set) - export BSBLAN_HOST=10.0.2.60 + export BSBLAN_HOST=192.0.2.1 export BSBLAN_PORT=80 export BSBLAN_PASSKEY=your_passkey # if needed