python-bsblan 5.0.0__tar.gz → 5.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/copilot-instructions.md +18 -0
  2. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/skills/bsblan-parameters/SKILL.md +51 -0
  3. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/PKG-INFO +1 -1
  4. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/pyproject.toml +2 -2
  5. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/bsblan.py +232 -48
  6. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/constants.py +124 -0
  7. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/models.py +11 -0
  8. python_bsblan-5.1.0/tests/fixtures/state_circuit2.json +89 -0
  9. python_bsblan-5.1.0/tests/fixtures/static_state_circuit2.json +16 -0
  10. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_api_validation.py +4 -0
  11. python_bsblan-5.1.0/tests/test_circuit.py +718 -0
  12. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_constants.py +11 -1
  13. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_entity_info.py +41 -0
  14. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_hotwater_state.py +4 -0
  15. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_initialization.py +4 -0
  16. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_temperature_validation.py +1 -1
  17. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/uv.lock +4 -4
  18. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.editorconfig +0 -0
  19. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.gitattributes +0 -0
  20. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/CODE_OF_CONDUCT.md +0 -0
  21. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/CONTRIBUTING.md +0 -0
  22. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  25. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/labels.yml +0 -0
  26. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/prompts/add-parameter.prompt.md +0 -0
  27. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/prompts/code-review.prompt.md +0 -0
  28. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/release-drafter.yml +0 -0
  29. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/renovate.json +0 -0
  30. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/skills/bsblan-testing/SKILL.md +0 -0
  31. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/auto-approve-renovate.yml +0 -0
  32. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/codeql.yaml +0 -0
  33. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/labels.yaml +0 -0
  34. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/linting.yaml +0 -0
  35. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/lock.yaml +0 -0
  36. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/pr-labels.yaml +0 -0
  37. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/release-drafter.yaml +0 -0
  38. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/release.yaml +0 -0
  39. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/stale.yaml +0 -0
  40. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/tests.yaml +0 -0
  41. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.gitignore +0 -0
  42. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.nvmrc +0 -0
  43. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.pre-commit-config.yaml +0 -0
  44. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.prettierignore +0 -0
  45. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.yamllint +0 -0
  46. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/AGENTS.md +0 -0
  47. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/CLAUDE.md +0 -0
  48. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/LICENSE.md +0 -0
  49. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/README.md +0 -0
  50. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/control.py +0 -0
  51. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/discovery.py +0 -0
  52. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/fetch_param.py +0 -0
  53. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/profile_init.py +0 -0
  54. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/ruff.toml +0 -0
  55. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/speed_test.py +0 -0
  56. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/package-lock.json +0 -0
  57. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/package.json +0 -0
  58. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/sonar-project.properties +0 -0
  59. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/__init__.py +0 -0
  60. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/exceptions.py +0 -0
  61. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/py.typed +0 -0
  62. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/utility.py +0 -0
  63. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/__init__.py +0 -0
  64. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/conftest.py +0 -0
  65. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/device.json +0 -0
  66. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/dict_version.json +0 -0
  67. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/hot_water_state.json +0 -0
  68. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/info.json +0 -0
  69. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/password.txt +0 -0
  70. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/sensor.json +0 -0
  71. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/state.json +0 -0
  72. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/static_state.json +0 -0
  73. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/thermostat_hvac.json +0 -0
  74. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/thermostat_temp.json +0 -0
  75. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/time.json +0 -0
  76. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/ruff.toml +0 -0
  77. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_api_initialization.py +0 -0
  78. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_auth.py +0 -0
  79. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_backoff_retry.py +0 -0
  80. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_bsblan.py +0 -0
  81. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_bsblan_edge_cases.py +0 -0
  82. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_configuration.py +0 -0
  83. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_context_manager.py +0 -0
  84. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_device.py +0 -0
  85. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_dhw_time_switch.py +0 -0
  86. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_entity_info_ha.py +0 -0
  87. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_hot_water_additional.py +0 -0
  88. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_include_parameter.py +0 -0
  89. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_info.py +0 -0
  90. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_read_parameters.py +0 -0
  91. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_reset_validation.py +0 -0
  92. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_schedule_models.py +0 -0
  93. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_sensor.py +0 -0
  94. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_set_hot_water_schedule.py +0 -0
  95. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_set_hotwater.py +0 -0
  96. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_state.py +0 -0
  97. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_static_state.py +0 -0
  98. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_temperature_unit.py +0 -0
  99. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_thermostat.py +0 -0
  100. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_time.py +0 -0
  101. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_utility.py +0 -0
  102. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_utility_additional.py +0 -0
  103. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_utility_edge_cases.py +0 -0
  104. {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_version_errors.py +0 -0
@@ -211,8 +211,26 @@ Test fixtures (JSON responses) are in `tests/fixtures/`
211
211
 
212
212
  ## Common Tasks
213
213
 
214
+ ### Fetching Parameters from a Real Device
215
+
216
+ Use `examples/fetch_param.py` to query raw parameter data from a real BSB-LAN device before adding new parameters:
217
+
218
+ ```bash
219
+ # Set your device connection details
220
+ # Leave BSBLAN_HOST unset to use auto-discovery, or set it explicitly:
221
+ export BSBLAN_HOST="192.168.1.100"
222
+ export BSBLAN_PASSKEY=your_passkey # if needed
223
+
224
+ # Fetch one or more parameters
225
+ cd examples && python fetch_param.py 1645
226
+ cd examples && python fetch_param.py 1645 1641 1642 1644 1646
227
+ ```
228
+
229
+ The output shows the raw JSON response including value, unit, description, and data type — use this to determine the correct model field type and naming.
230
+
214
231
  ### Adding a New Settable Parameter
215
232
 
233
+ 0. Fetch the parameter from a real device using `examples/fetch_param.py` to inspect the raw response
216
234
  1. Add parameter ID mapping in `constants.py`
217
235
  2. Add field to appropriate model in `models.py`
218
236
  3. Add parameter to method signature in `bsblan.py`
@@ -14,6 +14,57 @@ This skill guides you through adding new parameters to the python-bsblan library
14
14
  - Legionella-related parameters use `legionella_function_*` prefix
15
15
  - DHW (Domestic Hot Water) parameters use `dhw_*` prefix
16
16
 
17
+ ## Discovering Parameters from a Real System
18
+
19
+ Before adding a new parameter, use `examples/fetch_param.py` to retrieve the raw API response from a real BSB-LAN device. This shows the exact structure, data types, units, and descriptions returned by the device.
20
+
21
+ ### Setup
22
+
23
+ ```bash
24
+ # Set environment variables for your device
25
+ export BSBLAN_HOST=<ip-or-host> # Your BSB-LAN IP address; leave unset to use autodiscovery
26
+ export BSBLAN_PASSKEY=your_passkey # Optional: if your device requires a passkey
27
+ export BSBLAN_USER=username # Optional: if authentication is enabled
28
+ export BSBLAN_PASS=password # Optional: if authentication is enabled
29
+ export BSBLAN_PORT=80 # Optional: defaults to 80
30
+ ```
31
+
32
+ ### Fetching Parameters
33
+
34
+ ```bash
35
+ # Fetch a single parameter
36
+ cd examples && python fetch_param.py 1645
37
+
38
+ # Fetch multiple parameters at once
39
+ cd examples && python fetch_param.py 1645 1641 1642 1644 1646
40
+ ```
41
+
42
+ ### Example Output
43
+
44
+ The raw API response shows the exact structure you need to model:
45
+
46
+ ```json
47
+ {
48
+ "1645": {
49
+ "name": "Legionella function setpoint",
50
+ "value": "70.0",
51
+ "unit": "°C",
52
+ "desc": "",
53
+ "dataType": 0
54
+ }
55
+ }
56
+ ```
57
+
58
+ Use this output to determine:
59
+ - **Field type**: `float`, `int`, or `str` based on the `value` format
60
+ - **Unit**: The `unit` field (e.g., `°C`, `%`, `-`)
61
+ - **Description**: The `name` field for docstrings
62
+ - **Data type**: The `dataType` field for `EntityInfo` typing
63
+
64
+ ### Device Discovery
65
+
66
+ `fetch_param.py` uses mDNS/Zeroconf discovery (via `examples/discovery.py`) to find your BSB-LAN device automatically when `BSBLAN_HOST` is not set. If mDNS is unavailable, set the `BSBLAN_HOST` environment variable directly.
67
+
17
68
  ## Steps to Add a New Parameter
18
69
 
19
70
  ### 1. Add to `constants.py`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-bsblan
3
- Version: 5.0.0
3
+ Version: 5.1.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-bsblan"
3
- version = "5.0.0"
3
+ version = "5.1.0"
4
4
  description = "Asynchronous Python client for BSBLAN API"
5
5
  authors = [
6
6
  {name = "Willem-Jan van Rootselaar", email = "liudgervr@gmail.com"}
@@ -187,7 +187,7 @@ dev = [
187
187
  "flake8-simplify==0.30.0",
188
188
  # hatch is required to support type hinting and proper packaging of the py.typed file.
189
189
  "hatch>=1.14.1",
190
- "isort==7.0.0",
190
+ "isort==8.0.0",
191
191
  "ty==0.0.18",
192
192
  "prek>=0.3.3",
193
193
  "pre-commit-hooks==6.0.0",
@@ -22,13 +22,20 @@ from .constants import (
22
22
  API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG,
23
23
  API_VERSION_ERROR_MSG,
24
24
  API_VERSIONS,
25
+ CIRCUIT_HEATING_SECTIONS,
26
+ CIRCUIT_PROBE_PARAMS,
27
+ CIRCUIT_STATIC_SECTIONS,
28
+ CIRCUIT_THERMOSTAT_PARAMS,
25
29
  DHW_TIME_PROGRAM_PARAMS,
26
30
  EMPTY_INCLUDE_LIST_ERROR_MSG,
31
+ EMPTY_SECTION_PARAMS_ERROR_MSG,
27
32
  FIRMWARE_VERSION_ERROR_MSG,
28
33
  HOT_WATER_CONFIG_PARAMS,
29
34
  HOT_WATER_ESSENTIAL_PARAMS,
30
35
  HOT_WATER_SCHEDULE_PARAMS,
36
+ INVALID_CIRCUIT_ERROR_MSG,
31
37
  INVALID_INCLUDE_PARAMS_ERROR_MSG,
38
+ INVALID_RESPONSE_ERROR_MSG,
32
39
  MAX_VALID_YEAR,
33
40
  MIN_VALID_YEAR,
34
41
  MULTI_PARAMETER_ERROR_MSG,
@@ -37,9 +44,11 @@ from .constants import (
37
44
  NO_SCHEDULE_ERROR_MSG,
38
45
  NO_STATE_ERROR_MSG,
39
46
  PARAMETER_NAMES_NOT_RESOLVED_ERROR_MSG,
47
+ SECTION_NOT_FOUND_ERROR_MSG,
40
48
  SESSION_NOT_INITIALIZED_ERROR_MSG,
41
49
  SETTABLE_HOT_WATER_PARAMS,
42
50
  TEMPERATURE_RANGE_ERROR_MSG,
51
+ VALID_CIRCUITS,
43
52
  VALID_HVAC_MODES,
44
53
  VERSION_ERROR_MSG,
45
54
  APIConfig,
@@ -73,7 +82,17 @@ if TYPE_CHECKING:
73
82
 
74
83
  from aiohttp.client import ClientSession
75
84
 
76
- SectionLiteral = Literal["heating", "staticValues", "device", "sensor", "hot_water"]
85
+ SectionLiteral = Literal[
86
+ "heating",
87
+ "staticValues",
88
+ "device",
89
+ "sensor",
90
+ "hot_water",
91
+ "heating_circuit2",
92
+ "heating_circuit3",
93
+ "staticValues_circuit2",
94
+ "staticValues_circuit3",
95
+ ]
77
96
 
78
97
  # TypeVar for hot water data models
79
98
  HotWaterDataT = TypeVar(
@@ -114,6 +133,11 @@ class BSBLAN:
114
133
  _initialized: bool = False
115
134
  _api_validator: APIValidator = field(init=False)
116
135
  _temperature_unit: str = "°C"
136
+ # Per-circuit temperature ranges: circuit_number -> (min, max, initialized)
137
+ _circuit_temp_ranges: dict[int, dict[str, float | None]] = field(
138
+ default_factory=dict,
139
+ )
140
+ _circuit_temp_initialized: set[int] = field(default_factory=set)
117
141
  _hot_water_param_cache: dict[str, str] = field(default_factory=dict)
118
142
  # Track which hot water param groups have been validated
119
143
  _validated_hot_water_groups: set[str] = field(default_factory=set)
@@ -155,6 +179,40 @@ class BSBLAN:
155
179
  await self._setup_api_validator()
156
180
  self._initialized = True
157
181
 
182
+ async def get_available_circuits(self) -> list[int]:
183
+ """Detect which heating circuits are available on the device.
184
+
185
+ Probes the operating mode parameter for each circuit (1, 2, 3).
186
+ A circuit is considered available if the device returns a non-empty
187
+ response with a valid value (not empty ``{}``).
188
+
189
+ This is useful for integration setup flows (e.g., Home Assistant
190
+ config flow) to discover how many circuits the user's controller
191
+ supports.
192
+
193
+ Returns:
194
+ list[int]: Sorted list of available circuit numbers (e.g., [1, 2]).
195
+
196
+ Example:
197
+ async with BSBLAN(config) as client:
198
+ circuits = await client.get_available_circuits()
199
+ # circuits == [1, 2] for a dual-circuit controller
200
+
201
+ """
202
+ available: list[int] = []
203
+ for circuit, param_id in CIRCUIT_PROBE_PARAMS.items():
204
+ try:
205
+ response = await self._request(
206
+ params={"Parameter": param_id},
207
+ )
208
+ # A circuit exists if the response contains the param_id key
209
+ # with actual data (not an empty dict)
210
+ if response.get(param_id):
211
+ available.append(circuit)
212
+ except BSBLANError:
213
+ logger.debug("Circuit %d not available (request failed)", circuit)
214
+ return sorted(available)
215
+
158
216
  async def _setup_api_validator(self) -> None:
159
217
  """Set up the API validator without validating sections.
160
218
 
@@ -387,8 +445,8 @@ class BSBLAN:
387
445
  try:
388
446
  section_data = self._api_data[section]
389
447
  except KeyError as err:
390
- error_msg = f"Section '{section}' not found in API data"
391
- raise BSBLANError(error_msg) from err
448
+ msg = SECTION_NOT_FOUND_ERROR_MSG.format(section)
449
+ raise BSBLANError(msg) from err
392
450
 
393
451
  # Filter to only included params if specified
394
452
  if include is not None:
@@ -509,44 +567,96 @@ class BSBLAN:
509
567
  else:
510
568
  raise BSBLANVersionError(VERSION_ERROR_MSG)
511
569
 
512
- async def _initialize_temperature_range(self) -> None:
570
+ async def _fetch_temperature_range(
571
+ self,
572
+ circuit: int,
573
+ ) -> dict[str, float | None]:
574
+ """Fetch min/max temperature range for a circuit from the device.
575
+
576
+ Args:
577
+ circuit: The heating circuit number (1, 2, or 3).
578
+
579
+ Returns:
580
+ dict with 'min' and 'max' keys (values may be None if unavailable).
581
+
582
+ """
583
+ temp_range: dict[str, float | None] = {"min": None, "max": None}
584
+ try:
585
+ static_values = await self.static_values(circuit=circuit)
586
+ except BSBLANError as err:
587
+ logger.warning(
588
+ "Failed to get static values for circuit %d: %s. "
589
+ "Temperature range will be None",
590
+ circuit,
591
+ str(err),
592
+ )
593
+ return temp_range
594
+
595
+ if static_values.min_temp is not None:
596
+ temp_range["min"] = static_values.min_temp.value
597
+ logger.debug(
598
+ "Circuit %d min temp initialized: %s",
599
+ circuit,
600
+ temp_range["min"],
601
+ )
602
+
603
+ if static_values.max_temp is not None:
604
+ temp_range["max"] = static_values.max_temp.value
605
+ logger.debug(
606
+ "Circuit %d max temp initialized: %s",
607
+ circuit,
608
+ temp_range["max"],
609
+ )
610
+
611
+ return temp_range
612
+
613
+ async def _initialize_temperature_range(
614
+ self,
615
+ circuit: int = 1,
616
+ ) -> None:
513
617
  """Initialize the temperature range from static values (lazy loaded).
514
618
 
515
619
  This method is called on-demand when temperature range is needed.
516
620
  It uses lazy loading through static_values() which will validate
517
621
  the staticValues section if not already done.
518
622
 
623
+ Args:
624
+ circuit: The heating circuit number (1, 2, or 3).
625
+
519
626
  Note: Temperature unit is extracted during heating section validation
520
627
  from the response (parameter 710), so no extra API call is needed here.
628
+
521
629
  """
522
- if not self._temperature_range_initialized:
523
- # Try to get temperature range from static values (lazy loaded)
524
- try:
525
- static_values = await self.static_values()
526
- if static_values.min_temp is not None:
527
- self._min_temp = static_values.min_temp.value
528
- logger.debug("Min temperature initialized: %s", self._min_temp)
529
- else:
530
- logger.warning(
531
- "min_temp not available from device, "
532
- "temperature range will be None"
533
- )
630
+ if circuit == 1 and self._temperature_range_initialized:
631
+ return
632
+ if circuit != 1 and circuit in self._circuit_temp_initialized:
633
+ return
534
634
 
535
- if static_values.max_temp is not None:
536
- self._max_temp = static_values.max_temp.value
537
- logger.debug("Max temperature initialized: %s", self._max_temp)
538
- else:
539
- logger.warning(
540
- "max_temp not available from device, "
541
- "temperature range will be None"
542
- )
543
- except BSBLANError as err:
544
- logger.warning(
545
- "Failed to get static values: %s. Temperature range will be None",
546
- str(err),
547
- )
635
+ temp_range = await self._fetch_temperature_range(circuit)
548
636
 
637
+ if circuit == 1:
638
+ # HC1 uses legacy fields for backwards compatibility
639
+ self._min_temp = temp_range["min"]
640
+ self._max_temp = temp_range["max"]
549
641
  self._temperature_range_initialized = True
642
+ else:
643
+ # HC2/HC3 use per-circuit storage
644
+ self._circuit_temp_ranges[circuit] = temp_range
645
+ self._circuit_temp_initialized.add(circuit)
646
+
647
+ def _validate_circuit(self, circuit: int) -> None:
648
+ """Validate the circuit number.
649
+
650
+ Args:
651
+ circuit: The heating circuit number to validate.
652
+
653
+ Raises:
654
+ BSBLANInvalidParameterError: If the circuit number is invalid.
655
+
656
+ """
657
+ if circuit not in VALID_CIRCUITS:
658
+ msg = INVALID_CIRCUIT_ERROR_MSG.format(circuit)
659
+ raise BSBLANInvalidParameterError(msg)
550
660
 
551
661
  @property
552
662
  def get_temperature_unit(self) -> str:
@@ -690,8 +800,8 @@ class BSBLAN:
690
800
  raise
691
801
  except (ValueError, UnicodeDecodeError) as e:
692
802
  # Handle JSON decode errors and other parsing issues
693
- error_msg = f"Invalid response format from BSB-LAN device: {e!s}"
694
- raise BSBLANError(error_msg) from e
803
+ msg = INVALID_RESPONSE_ERROR_MSG.format(e)
804
+ raise BSBLANError(msg) from e
695
805
 
696
806
  def _process_response(
697
807
  self, response_data: dict[str, Any], base_path: str
@@ -826,6 +936,11 @@ class BSBLAN:
826
936
 
827
937
  section_params = self._api_validator.get_section_params(section)
828
938
 
939
+ # Guard: if validation removed all params, the section is not available
940
+ if not section_params:
941
+ msg = EMPTY_SECTION_PARAMS_ERROR_MSG.format(section)
942
+ raise BSBLANError(msg)
943
+
829
944
  # Filter parameters if include list is specified
830
945
  if include is not None:
831
946
  if not include:
@@ -843,7 +958,11 @@ class BSBLAN:
843
958
  data = dict(zip(params["list"], list(data.values()), strict=True))
844
959
  return model_class.model_validate(data)
845
960
 
846
- async def state(self, include: list[str] | None = None) -> State:
961
+ async def state(
962
+ self,
963
+ include: list[str] | None = None,
964
+ circuit: int = 1,
965
+ ) -> State:
847
966
  """Get the current state from BSBLAN device.
848
967
 
849
968
  Args:
@@ -852,6 +971,9 @@ class BSBLAN:
852
971
  hvac_mode, target_temperature, hvac_action,
853
972
  hvac_mode_changeover, current_temperature,
854
973
  room1_thermostat_mode, room1_temp_setpoint_boost.
974
+ circuit: The heating circuit number (1, 2, or 3). Defaults to 1.
975
+ Circuit 2 and 3 use separate parameter IDs but return the
976
+ same State model with the same field names.
855
977
 
856
978
  Returns:
857
979
  State: The current state of the BSBLAN device.
@@ -864,8 +986,15 @@ class BSBLAN:
864
986
  # Fetch only hvac_mode and current_temperature
865
987
  state = await client.state(include=["hvac_mode", "current_temperature"])
866
988
 
989
+ # Fetch state for heating circuit 2
990
+ state_hc2 = await client.state(circuit=2)
991
+
867
992
  """
868
- return await self._fetch_section_data("heating", State, include)
993
+ self._validate_circuit(circuit)
994
+ section: SectionLiteral = cast(
995
+ "SectionLiteral", CIRCUIT_HEATING_SECTIONS[circuit]
996
+ )
997
+ return await self._fetch_section_data(section, State, include)
869
998
 
870
999
  async def sensor(self, include: list[str] | None = None) -> Sensor:
871
1000
  """Get the sensor information from BSBLAN device.
@@ -885,13 +1014,18 @@ class BSBLAN:
885
1014
  """
886
1015
  return await self._fetch_section_data("sensor", Sensor, include)
887
1016
 
888
- async def static_values(self, include: list[str] | None = None) -> StaticState:
1017
+ async def static_values(
1018
+ self,
1019
+ include: list[str] | None = None,
1020
+ circuit: int = 1,
1021
+ ) -> StaticState:
889
1022
  """Get the static information from BSBLAN device.
890
1023
 
891
1024
  Args:
892
1025
  include: Optional list of parameter names to fetch. If None,
893
1026
  fetches all static parameters. Valid names include:
894
1027
  min_temp, max_temp.
1028
+ circuit: The heating circuit number (1, 2, or 3). Defaults to 1.
895
1029
 
896
1030
  Returns:
897
1031
  StaticState: The static information from the BSBLAN device.
@@ -900,8 +1034,15 @@ class BSBLAN:
900
1034
  # Fetch only min_temp
901
1035
  static = await client.static_values(include=["min_temp"])
902
1036
 
1037
+ # Fetch static values for heating circuit 2
1038
+ static_hc2 = await client.static_values(circuit=2)
1039
+
903
1040
  """
904
- return await self._fetch_section_data("staticValues", StaticState, include)
1041
+ self._validate_circuit(circuit)
1042
+ section: SectionLiteral = cast(
1043
+ "SectionLiteral", CIRCUIT_STATIC_SECTIONS[circuit]
1044
+ )
1045
+ return await self._fetch_section_data(section, StaticState, include)
905
1046
 
906
1047
  async def device(self) -> Device:
907
1048
  """Get BSBLAN device info.
@@ -968,6 +1109,7 @@ class BSBLAN:
968
1109
  self,
969
1110
  target_temperature: str | None = None,
970
1111
  hvac_mode: int | None = None,
1112
+ circuit: int = 1,
971
1113
  ) -> None:
972
1114
  """Change the state of the thermostat through BSB-Lan.
973
1115
 
@@ -975,9 +1117,18 @@ class BSBLAN:
975
1117
  target_temperature (str | None): The target temperature to set.
976
1118
  hvac_mode (int | None): The HVAC mode to set as raw integer value.
977
1119
  Valid values: 0=off, 1=auto, 2=eco, 3=heat.
1120
+ circuit: The heating circuit number (1, 2, or 3). Defaults to 1.
1121
+
1122
+ Example:
1123
+ # Set HC1 temperature
1124
+ await client.thermostat(target_temperature="21.0")
1125
+
1126
+ # Set HC2 mode
1127
+ await client.thermostat(hvac_mode=1, circuit=2)
978
1128
 
979
1129
  """
980
- await self._initialize_temperature_range()
1130
+ self._validate_circuit(circuit)
1131
+ await self._initialize_temperature_range(circuit)
981
1132
 
982
1133
  self._validate_single_parameter(
983
1134
  target_temperature,
@@ -985,65 +1136,98 @@ class BSBLAN:
985
1136
  error_msg=MULTI_PARAMETER_ERROR_MSG,
986
1137
  )
987
1138
 
988
- state = await self._prepare_thermostat_state(target_temperature, hvac_mode)
1139
+ state = await self._prepare_thermostat_state(
1140
+ target_temperature,
1141
+ hvac_mode,
1142
+ circuit,
1143
+ )
989
1144
  await self._set_device_state(state)
990
1145
 
991
1146
  async def _prepare_thermostat_state(
992
1147
  self,
993
1148
  target_temperature: str | None,
994
1149
  hvac_mode: int | None,
1150
+ circuit: int = 1,
995
1151
  ) -> dict[str, Any]:
996
1152
  """Prepare the thermostat state for setting.
997
1153
 
998
1154
  Args:
999
1155
  target_temperature (str | None): The target temperature to set.
1000
1156
  hvac_mode (int | None): The HVAC mode to set as raw integer.
1157
+ circuit: The heating circuit number (1, 2, or 3).
1001
1158
 
1002
1159
  Returns:
1003
1160
  dict[str, Any]: The prepared state for the thermostat.
1004
1161
 
1005
1162
  """
1163
+ param_ids = CIRCUIT_THERMOSTAT_PARAMS[circuit]
1006
1164
  state: dict[str, Any] = {}
1007
1165
  if target_temperature is not None:
1008
- await self._validate_target_temperature(target_temperature)
1166
+ await self._validate_target_temperature(
1167
+ target_temperature,
1168
+ circuit,
1169
+ )
1009
1170
  state.update(
1010
- {"Parameter": "710", "Value": target_temperature, "Type": "1"},
1171
+ {
1172
+ "Parameter": param_ids["target_temperature"],
1173
+ "Value": target_temperature,
1174
+ "Type": "1",
1175
+ },
1011
1176
  )
1012
1177
  if hvac_mode is not None:
1013
1178
  self._validate_hvac_mode(hvac_mode)
1014
1179
  state.update(
1015
1180
  {
1016
- "Parameter": "700",
1181
+ "Parameter": param_ids["hvac_mode"],
1017
1182
  "Value": str(hvac_mode),
1018
1183
  "Type": "1",
1019
1184
  },
1020
1185
  )
1021
1186
  return state
1022
1187
 
1023
- async def _validate_target_temperature(self, target_temperature: str) -> None:
1188
+ async def _validate_target_temperature(
1189
+ self,
1190
+ target_temperature: str,
1191
+ circuit: int = 1,
1192
+ ) -> None:
1024
1193
  """Validate the target temperature.
1025
1194
 
1026
1195
  This method lazy-loads the temperature range if not already initialized.
1027
1196
 
1028
1197
  Args:
1029
1198
  target_temperature (str): The target temperature to validate.
1199
+ circuit: The heating circuit number (1, 2, or 3).
1030
1200
 
1031
1201
  Raises:
1032
1202
  BSBLANError: If the temperature range cannot be initialized.
1033
1203
  BSBLANInvalidParameterError: If the target temperature is invalid.
1034
1204
 
1035
1205
  """
1036
- # Lazy load temperature range if needed
1037
- if self._min_temp is None or self._max_temp is None:
1038
- await self._initialize_temperature_range()
1206
+ if circuit == 1:
1207
+ # HC1 uses legacy fields for backwards compatibility
1208
+ if self._min_temp is None or self._max_temp is None:
1209
+ await self._initialize_temperature_range(circuit)
1210
+
1211
+ if self._min_temp is None or self._max_temp is None:
1212
+ raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG)
1213
+
1214
+ min_temp = self._min_temp
1215
+ max_temp = self._max_temp
1216
+ else:
1217
+ # HC2/HC3 use per-circuit storage
1218
+ if circuit not in self._circuit_temp_initialized:
1219
+ await self._initialize_temperature_range(circuit)
1220
+
1221
+ temp_range = self._circuit_temp_ranges.get(circuit, {})
1222
+ min_temp = temp_range.get("min")
1223
+ max_temp = temp_range.get("max")
1039
1224
 
1040
- # After initialization attempt, check if we have the range
1041
- if self._min_temp is None or self._max_temp is None:
1042
- raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG)
1225
+ if min_temp is None or max_temp is None:
1226
+ raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG)
1043
1227
 
1044
1228
  try:
1045
1229
  temp = float(target_temperature)
1046
- if not (self._min_temp <= temp <= self._max_temp):
1230
+ if not (min_temp <= temp <= max_temp):
1047
1231
  raise BSBLANInvalidParameterError(target_temperature)
1048
1232
  except ValueError as err:
1049
1233
  raise BSBLANInvalidParameterError(target_temperature) from err