python-bsblan 5.2.1__tar.gz → 6.0.1__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 (123) hide show
  1. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/codeql.yaml +2 -2
  2. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/dependency-review.yaml +1 -1
  3. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/release-drafter.yaml +1 -1
  4. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/scorecard.yml +1 -1
  5. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/tests.yaml +1 -1
  6. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/PKG-INFO +2 -2
  7. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/docs/api/client.md +2 -0
  8. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/docs/getting-started.md +36 -0
  9. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/examples/control.py +34 -14
  10. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/pyproject.toml +6 -6
  11. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/src/bsblan/bsblan.py +144 -22
  12. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/src/bsblan/constants.py +26 -0
  13. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/src/bsblan/models.py +20 -0
  14. python_bsblan-6.0.1/tests/fixtures/pps_device.json +16 -0
  15. python_bsblan-6.0.1/tests/fixtures/pps_state.json +23 -0
  16. python_bsblan-6.0.1/tests/fixtures/pps_static_values.json +16 -0
  17. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_device.py +5 -0
  18. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_include_parameter.py +31 -0
  19. python_bsblan-6.0.1/tests/test_pps.py +380 -0
  20. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_time.py +13 -1
  21. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.editorconfig +0 -0
  22. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.gitattributes +0 -0
  23. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/CODE_OF_CONDUCT.md +0 -0
  24. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/CONTRIBUTING.md +0 -0
  25. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
  26. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  27. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  28. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/SECURITY.md +0 -0
  29. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/copilot-instructions.md +0 -0
  30. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/labels.yml +0 -0
  31. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/prompts/add-parameter.prompt.md +0 -0
  32. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/prompts/code-review.prompt.md +0 -0
  33. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/release-drafter.yml +0 -0
  34. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/renovate.json +0 -0
  35. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/skills/bsblan-parameters/SKILL.md +0 -0
  36. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/skills/bsblan-testing/SKILL.md +0 -0
  37. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/auto-approve-renovate.yml +0 -0
  38. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/docs.yaml +0 -0
  39. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/labels.yaml +0 -0
  40. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/linting.yaml +0 -0
  41. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/lock.yaml +0 -0
  42. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/pr-labels.yaml +0 -0
  43. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/release.yaml +0 -0
  44. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/stale.yaml +0 -0
  45. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/workflows/typing.yaml +0 -0
  46. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.github/zizmor.yml +0 -0
  47. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.gitignore +0 -0
  48. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.nvmrc +0 -0
  49. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.pre-commit-config.yaml +0 -0
  50. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.prettierignore +0 -0
  51. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/.yamllint +0 -0
  52. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/AGENTS.md +0 -0
  53. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/CLAUDE.md +0 -0
  54. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/LICENSE.md +0 -0
  55. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/Makefile +0 -0
  56. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/README.md +0 -0
  57. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/docs/api/constants.md +0 -0
  58. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/docs/api/exceptions.md +0 -0
  59. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/docs/api/models.md +0 -0
  60. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/docs/index.md +0 -0
  61. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/examples/discovery.py +0 -0
  62. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/examples/fetch_param.py +0 -0
  63. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/examples/profile_init.py +0 -0
  64. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/examples/ruff.toml +0 -0
  65. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/examples/speed_test.py +0 -0
  66. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/mkdocs.yml +0 -0
  67. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/package-lock.json +0 -0
  68. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/package.json +0 -0
  69. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/sonar-project.properties +0 -0
  70. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/src/bsblan/__init__.py +0 -0
  71. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/src/bsblan/exceptions.py +0 -0
  72. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/src/bsblan/py.typed +0 -0
  73. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/src/bsblan/utility.py +0 -0
  74. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/__init__.py +0 -0
  75. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/conftest.py +0 -0
  76. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/device.json +0 -0
  77. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/dict_version.json +0 -0
  78. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/hot_water_state.json +0 -0
  79. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/info.json +0 -0
  80. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/password.txt +0 -0
  81. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/sensor.json +0 -0
  82. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/state.json +0 -0
  83. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/state_circuit2.json +0 -0
  84. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/static_state.json +0 -0
  85. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/static_state_circuit2.json +0 -0
  86. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/thermostat_hvac.json +0 -0
  87. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/thermostat_temp.json +0 -0
  88. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/fixtures/time.json +0 -0
  89. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/ruff.toml +0 -0
  90. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_api_initialization.py +0 -0
  91. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_api_validation.py +0 -0
  92. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_auth.py +0 -0
  93. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_backoff_retry.py +0 -0
  94. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_bsblan.py +0 -0
  95. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_bsblan_edge_cases.py +0 -0
  96. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_circuit.py +0 -0
  97. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_configuration.py +0 -0
  98. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_constants.py +0 -0
  99. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_context_manager.py +0 -0
  100. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_dhw_time_switch.py +0 -0
  101. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_entity_info.py +0 -0
  102. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_entity_info_ha.py +0 -0
  103. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_heating_schedule.py +0 -0
  104. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_hot_water_additional.py +0 -0
  105. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_hotwater_state.py +0 -0
  106. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_info.py +0 -0
  107. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_initialization.py +0 -0
  108. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_read_parameters.py +0 -0
  109. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_reset_validation.py +0 -0
  110. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_schedule_models.py +0 -0
  111. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_sensor.py +0 -0
  112. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_set_heating_schedule.py +0 -0
  113. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_set_hot_water_schedule.py +0 -0
  114. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_set_hotwater.py +0 -0
  115. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_state.py +0 -0
  116. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_static_state.py +0 -0
  117. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_temperature_unit.py +0 -0
  118. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_temperature_validation.py +0 -0
  119. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_thermostat.py +0 -0
  120. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_utility.py +0 -0
  121. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_utility_additional.py +0 -0
  122. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_utility_edge_cases.py +0 -0
  123. {python_bsblan-5.2.1 → python_bsblan-6.0.1}/tests/test_version_errors.py +0 -0
@@ -27,6 +27,6 @@ jobs:
27
27
  with:
28
28
  persist-credentials: false
29
29
  - name: 🏗 Initialize CodeQL
30
- uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
30
+ uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
31
31
  - name: 🚀 Perform CodeQL Analysis
32
- uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
32
+ uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
@@ -18,4 +18,4 @@ jobs:
18
18
  persist-credentials: false
19
19
  - name: 👀 Dependency review
20
20
  # yamllint disable-line rule:line-length
21
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
21
+ uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
@@ -36,7 +36,7 @@ jobs:
36
36
  steps:
37
37
  - name: 🚀 Run Release Drafter
38
38
  # yamllint disable-line rule:line-length
39
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
39
+ uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
40
40
  with:
41
41
  prerelease: ${{ github.event.inputs.prerelease == 'true' }}
42
42
  prerelease-identifier: ${{ github.event.inputs.prerelease_identifier }}
@@ -66,6 +66,6 @@ jobs:
66
66
  # Upload the results to GitHub's code scanning dashboard (optional).
67
67
  - name: "Upload to code-scanning"
68
68
  # yamllint disable-line rule:line-length
69
- uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
69
+ uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
70
70
  with:
71
71
  sarif_file: results.sarif
@@ -84,7 +84,7 @@ jobs:
84
84
  uv run python -m coverage xml -i
85
85
  - name: 🚀 Upload coverage report
86
86
  if: env.HAS_CODECOV_TOKEN == 'true'
87
- uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
87
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
88
88
  with:
89
89
  token: ${{ secrets.CODECOV_TOKEN }}
90
90
  - name: ℹ️ Skip Codecov upload (missing token)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-bsblan
3
- Version: 5.2.1
3
+ Version: 6.0.1
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
@@ -24,9 +24,9 @@ Classifier: Programming Language :: Python :: 3.14
24
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
25
  Requires-Python: >=3.12
26
26
  Requires-Dist: aiohttp>=3.8.1
27
- Requires-Dist: backoff>=2.2.1
28
27
  Requires-Dist: packaging>=21.3
29
28
  Requires-Dist: pydantic>=2.0
29
+ Requires-Dist: python-backoff>=2.3.1
30
30
  Requires-Dist: yarl>=1.7.2
31
31
  Description-Content-Type: text/markdown
32
32
 
@@ -10,6 +10,8 @@ The main entry point for interacting with BSB-LAN devices.
10
10
  members:
11
11
  - __init__
12
12
  - initialize
13
+ - device_info
14
+ - supports_time_sync
13
15
  - get_available_circuits
14
16
  - state
15
17
  - sensor
@@ -63,6 +63,42 @@ async def main() -> None:
63
63
  asyncio.run(main())
64
64
  ```
65
65
 
66
+ ## PPS bus support
67
+
68
+ PPS bus devices are detected from the device metadata returned by BSB-LAN. The
69
+ client provides minimal climate support for PPS devices through the same climate
70
+ methods used by BSB/LPB devices.
71
+
72
+ Supported PPS climate operations:
73
+
74
+ - `state()` for `hvac_mode`, `target_temperature`, and `current_temperature`
75
+ - `static_values()` for `min_temp` and `max_temp`
76
+ - `thermostat()` for target temperature and HVAC mode
77
+ - `get_available_circuits()`, which returns `[1]` when the single PPS
78
+ climate circuit is available, otherwise `[]`; PPS devices only ever expose
79
+ circuit `1`
80
+
81
+ PPS devices currently have these limitations:
82
+
83
+ - Only circuit `1` is supported.
84
+ - `time()` and `set_time()` are not supported for PPS devices.
85
+ - `thermostat(hvac_mode=2)` is not supported on PPS devices. Valid PPS modes
86
+ are `0` (off), `1` (auto), and `3` (heat/manual).
87
+ - Hot water and schedule helpers are intended for BSB/LPB devices.
88
+
89
+ Check `supports_time_sync` before showing or calling time synchronization in
90
+ applications:
91
+
92
+ ```python
93
+ async with BSBLAN(config) as client:
94
+ device = client.device_info or await client.device()
95
+ print(f"Bus type: {device.bus or 'unknown'}")
96
+
97
+ if client.supports_time_sync:
98
+ device_time = await client.time()
99
+ print(device_time.time.value)
100
+ ```
101
+
66
102
  ## Hot water control
67
103
 
68
104
  ```python
@@ -68,6 +68,16 @@ def print_attributes(title: str, attributes: dict[str, str]) -> None:
68
68
  print(f"{label}: {value}")
69
69
 
70
70
 
71
+ def format_yes_no(*, value: bool) -> str:
72
+ """Format a boolean as a readable yes/no value."""
73
+ return "yes" if value else "no"
74
+
75
+
76
+ def format_optional(value: Any) -> str:
77
+ """Format optional device metadata for display."""
78
+ return "N/A" if value is None else str(value)
79
+
80
+
71
81
  def get_hvac_action_name(status_code: int) -> str:
72
82
  """Map BSB-LAN parameter 8000 status code to a human-readable HVAC action.
73
83
 
@@ -180,6 +190,11 @@ async def print_device_info(device: Device, info: Info) -> None:
180
190
  "Device Name": device.name or "N/A",
181
191
  "Version": device.version or "N/A",
182
192
  "Device Identification": device_identification,
193
+ "Bus Type": format_optional(device.bus),
194
+ "Bus Writable Flag": format_optional(device.buswritable),
195
+ "Bus Address": format_optional(device.busaddr),
196
+ "Bus Destination": format_optional(device.busdest),
197
+ "Supports Time Sync": format_yes_no(value=device.supports_time_sync),
183
198
  }
184
199
  print_attributes("Device Information", attributes)
185
200
 
@@ -319,6 +334,11 @@ async def main() -> None:
319
334
 
320
335
  # Initialize BSBLAN with the configuration object
321
336
  async with BSBLAN(config) as bsblan:
337
+ # Get and print device and general info, including bus metadata
338
+ device: Device = bsblan.device_info or await bsblan.device()
339
+ info: Info = await bsblan.info()
340
+ await print_device_info(device, info)
341
+
322
342
  # Get and print state
323
343
  state: State = await bsblan.state()
324
344
  await print_state(state)
@@ -335,14 +355,12 @@ async def main() -> None:
335
355
  sensor: Sensor = await bsblan.sensor()
336
356
  await print_sensor(sensor)
337
357
 
338
- # Get and print device and general info
339
- device: Device = await bsblan.device()
340
- info: Info = await bsblan.info()
341
- await print_device_info(device, info)
342
-
343
358
  # Get and print device time
344
- device_time: DeviceTime = await bsblan.time()
345
- await print_device_time(device_time)
359
+ if bsblan.supports_time_sync:
360
+ device_time: DeviceTime = await bsblan.time()
361
+ await print_device_time(device_time)
362
+ else:
363
+ print("\nDevice time is not available for this bus type")
346
364
 
347
365
  # Get and print static state
348
366
  static_state: StaticState = await bsblan.static_values()
@@ -375,13 +393,15 @@ async def main() -> None:
375
393
  await bsblan.set_hot_water(SetHotWaterParam(dhw_time_programs=dhw_programs))
376
394
 
377
395
  # Example: Set device time
378
- print("\nSetting device time to current system time")
379
- # Get current local system time and format it for BSB-LAN (DD.MM.YYYY HH:MM:SS)
380
- # Note: Using local time intentionally for this demo to sync BSB-LAN
381
- current_time = datetime.now().replace(microsecond=0) # noqa: DTZ005 - Demo uses local time
382
- formatted_time = current_time.strftime("%d.%m.%Y %H:%M:%S")
383
- print(f"Current system time: {formatted_time}")
384
- await bsblan.set_time(formatted_time)
396
+ if bsblan.supports_time_sync:
397
+ print("\nSetting device time to current system time")
398
+ # Get current local system time and format it for BSB-LAN.
399
+ current_time = datetime.now().replace(microsecond=0) # noqa: DTZ005
400
+ formatted_time = current_time.strftime("%d.%m.%Y %H:%M:%S")
401
+ print(f"Current system time: {formatted_time}")
402
+ await bsblan.set_time(formatted_time)
403
+ else:
404
+ print("\nSkipping device time sync for this bus type")
385
405
 
386
406
 
387
407
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-bsblan"
3
- version = "5.2.1"
3
+ version = "6.0.1"
4
4
  description = "Asynchronous Python client for BSBLAN API"
5
5
  authors = [
6
6
  {name = "Willem-Jan van Rootselaar", email = "liudgervr@gmail.com"}
@@ -28,7 +28,7 @@ dependencies = [
28
28
  "aiohttp>=3.8.1",
29
29
  "yarl>=1.7.2",
30
30
  "packaging>=21.3",
31
- "backoff>=2.2.1",
31
+ "python-backoff>=2.3.1",
32
32
  "pydantic>=2.0",
33
33
  ]
34
34
 
@@ -185,18 +185,18 @@ build-backend = "hatchling.build"
185
185
  dev = [
186
186
  "aresponses==3.0.0",
187
187
  "bandit==1.9.4",
188
- "black==26.3.1",
188
+ "black==26.5.1",
189
189
  "blacken-docs==1.20.0",
190
190
  "codespell==2.4.2",
191
191
  "covdefaults==2.3.0",
192
- "coverage==7.13.5",
192
+ "coverage==7.14.0",
193
193
  "darglint==1.8.1",
194
194
  "flake8==7.3.0",
195
195
  "flake8-simplify==0.30.0",
196
196
  # hatch is required to support type hinting and proper packaging of the py.typed file.
197
197
  "hatch>=1.14.1",
198
198
  "isort==8.0.1",
199
- "ty==0.0.34",
199
+ "ty==0.0.38",
200
200
  "prek>=0.3.3",
201
201
  "pre-commit-hooks==6.0.0",
202
202
  "pylint==4.0.5",
@@ -205,7 +205,7 @@ dev = [
205
205
  "pytest-cov==7.1.0",
206
206
  "pytest-xdist>=3.8.0",
207
207
  "pyupgrade==3.21.2",
208
- "ruff==0.15.12",
208
+ "ruff==0.15.13",
209
209
  "safety==3.7.0",
210
210
  "vulture==2.16",
211
211
  "yamllint==1.38.0",
@@ -19,6 +19,8 @@ from yarl import URL
19
19
 
20
20
  from .constants import (
21
21
  API_VERSIONS,
22
+ PPS_HEATING_PARAMS,
23
+ PPS_STATIC_VALUES_PARAMS,
22
24
  APIConfig,
23
25
  CircuitConfig,
24
26
  ErrorMsg,
@@ -100,6 +102,7 @@ class BSBLAN:
100
102
  _firmware_version: str | None = None
101
103
  _api_version: str | None = None
102
104
  _api_data: APIConfig | None = None
105
+ _device: Device | None = None
103
106
  _initialized: bool = False
104
107
  _api_validator: APIValidator = field(init=False)
105
108
  _temperature_unit: str = "°C"
@@ -170,6 +173,9 @@ class BSBLAN:
170
173
  # circuits == [1, 2] for a dual-circuit controller
171
174
 
172
175
  """
176
+ if self._uses_pps_bus:
177
+ return await self._get_available_pps_circuits()
178
+
173
179
  available: list[int] = []
174
180
  for circuit, param_id in CircuitConfig.PROBE_PARAMS.items():
175
181
  try:
@@ -195,6 +201,20 @@ class BSBLAN:
195
201
  available.append(circuit)
196
202
  return sorted(available)
197
203
 
204
+ async def _get_available_pps_circuits(self) -> list[int]:
205
+ """Detect the single PPS room-unit climate circuit."""
206
+ param_id = "15000"
207
+ try:
208
+ response = await self._request(params={"Parameter": param_id})
209
+ except BSBLANError:
210
+ logger.debug("PPS climate circuit not available")
211
+ return []
212
+
213
+ if not response.get(param_id):
214
+ logger.debug("PPS climate circuit has no operating mode data")
215
+ return []
216
+ return [1]
217
+
198
218
  async def _setup_api_validator(self) -> None:
199
219
  """Set up the API validator without validating sections.
200
220
 
@@ -208,9 +228,21 @@ class BSBLAN:
208
228
  if self._api_data is None:
209
229
  self._api_data = self._copy_api_config()
210
230
 
231
+ self._apply_bus_specific_api_config()
232
+
211
233
  # Initialize the API validator (but don't validate sections yet)
212
234
  self._api_validator = APIValidator(self._api_data)
213
235
 
236
+ def _apply_bus_specific_api_config(self) -> None:
237
+ """Apply bus-specific parameter maps to the current API config."""
238
+ if self._api_data is None or not self._uses_pps_bus:
239
+ return
240
+
241
+ self._api_data["heating"] = PPS_HEATING_PARAMS.copy()
242
+ self._api_data["staticValues"] = PPS_STATIC_VALUES_PARAMS.copy()
243
+ self._api_data["heating_circuit2"] = {}
244
+ self._api_data["staticValues_circuit2"] = {}
245
+
214
246
  async def _ensure_section_validated(
215
247
  self, section: SectionLiteral, include: list[str] | None = None
216
248
  ) -> None:
@@ -247,11 +279,26 @@ class BSBLAN:
247
279
  logger.debug("Lazy loading section: %s", section)
248
280
  response_data = await self._validate_api_section(section, include)
249
281
 
250
- # Extract temperature unit from heating section validation
251
- # (parameter 710 - target_temperature is always in heating section)
252
- if section == "heating" and response_data:
282
+ if response_data and self._should_extract_temperature_unit(
283
+ section, include, response_data
284
+ ):
253
285
  self._extract_temperature_unit_from_response(response_data)
254
286
 
287
+ def _should_extract_temperature_unit(
288
+ self,
289
+ section: SectionLiteral,
290
+ include: list[str] | None,
291
+ response_data: dict[str, Any],
292
+ ) -> bool:
293
+ """Return whether the validation response should update temperature unit."""
294
+ if section != "heating":
295
+ return False
296
+
297
+ if include is None or "target_temperature" in include:
298
+ return True
299
+
300
+ return any(param_id in response_data for param_id in ("710", "15004"))
301
+
255
302
  async def _ensure_hot_water_group_validated(
256
303
  self,
257
304
  group_name: str,
@@ -444,17 +491,16 @@ class BSBLAN:
444
491
  ) -> None:
445
492
  """Extract temperature unit from heating section response data.
446
493
 
447
- Gets the unit from parameter 710 (target_temperature) which is always
494
+ Gets the unit from the target_temperature parameter, which is always
448
495
  present in the heating section.
449
496
 
450
497
  Args:
451
498
  response_data: The response data from heating section validation
452
499
 
453
500
  """
454
- # Look for parameter 710 (target_temperature) in the response
501
+ # Look for target_temperature in the response.
455
502
  for param_id, param_data in response_data.items():
456
- # Check if this is parameter 710 and has unit information
457
- if param_id == "710" and isinstance(param_data, dict):
503
+ if param_id in {"710", "15004"} and isinstance(param_data, dict):
458
504
  unit = param_data.get("unit", "")
459
505
  if unit in ("°C", "°C"):
460
506
  self._temperature_unit = "°C"
@@ -463,16 +509,15 @@ class BSBLAN:
463
509
  else:
464
510
  # Keep default if unit is empty or unknown
465
511
  logger.debug(
466
- "Unknown or empty temperature unit from parameter 710: '%s'. "
467
- "Using default (°C)",
512
+ "Unknown or empty temperature unit from heating target: "
513
+ "'%s'. Using default (°C)",
468
514
  unit,
469
515
  )
470
516
  logger.debug("Temperature unit set to: %s", self._temperature_unit)
471
517
  return
472
518
 
473
- # If we didn't find parameter 710, log a warning
474
519
  logger.warning(
475
- "Could not find parameter 710 in heating section response. "
520
+ "Could not find target temperature in heating section response. "
476
521
  "Using default temperature unit (°C)"
477
522
  )
478
523
 
@@ -494,6 +539,31 @@ class BSBLAN:
494
539
  logger.debug("BSBLAN version: %s", self._firmware_version)
495
540
  self._set_api_version()
496
541
 
542
+ @property
543
+ def device_info(self) -> Device | None:
544
+ """Return cached device metadata from the last /JI response."""
545
+ return self._device
546
+
547
+ @property
548
+ def supports_time_sync(self) -> bool:
549
+ """Return cached support for the normal BSB/LPB time sync command."""
550
+ return self._device is not None and self._device.supports_time_sync
551
+
552
+ @property
553
+ def _uses_pps_bus(self) -> bool:
554
+ """Return whether cached metadata identifies the device as PPS."""
555
+ return self._device is not None and self._device.is_pps_bus
556
+
557
+ @property
558
+ def _is_bus_writable(self) -> bool:
559
+ """Return whether cached metadata says writes are allowed."""
560
+ return self._device is None or self._device.is_bus_writable
561
+
562
+ async def _ensure_device_metadata(self) -> None:
563
+ """Fetch device metadata if it has not been loaded yet."""
564
+ if self._device is None:
565
+ await self.device()
566
+
497
567
  def _set_api_version(self) -> None:
498
568
  """Set the API version based on the firmware version.
499
569
 
@@ -572,8 +642,8 @@ class BSBLAN:
572
642
  Args:
573
643
  circuit: The heating circuit number (1 or 2).
574
644
 
575
- Note: Temperature unit is extracted during heating section validation
576
- from the response (parameter 710), so no extra API call is needed here.
645
+ Note: Temperature unit is extracted during heating section validation,
646
+ so no extra API call is needed here.
577
647
 
578
648
  """
579
649
  if circuit in self._circuit_temp_initialized:
@@ -593,10 +663,20 @@ class BSBLAN:
593
663
  BSBLANInvalidParameterError: If the circuit number is invalid.
594
664
 
595
665
  """
596
- if circuit not in CircuitConfig.VALID:
666
+ if circuit not in CircuitConfig.VALID or (self._uses_pps_bus and circuit != 1):
597
667
  msg = ErrorMsg.INVALID_CIRCUIT.format(circuit)
598
668
  raise BSBLANInvalidParameterError(msg)
599
669
 
670
+ def _validate_bus_write_supported(self) -> None:
671
+ """Validate that cached metadata permits writes."""
672
+ if not self._is_bus_writable:
673
+ raise BSBLANError(ErrorMsg.BUS_WRITE_NOT_SUPPORTED)
674
+
675
+ def _validate_time_sync_supported(self) -> None:
676
+ """Validate that normal parameter 0 time sync is safe."""
677
+ if not self.supports_time_sync:
678
+ raise BSBLANError(ErrorMsg.TIME_SYNC_NOT_SUPPORTED)
679
+
600
680
  @property
601
681
  def get_temperature_unit(self) -> str:
602
682
  """Get the unit of temperature.
@@ -878,8 +958,26 @@ class BSBLAN:
878
958
  params = self._extract_params_summary(section_params)
879
959
  data = await self._request(params={"Parameter": params["string_par"]})
880
960
  data = dict(zip(params["list"], list(data.values()), strict=True))
961
+ if section == "heating" and self._uses_pps_bus:
962
+ self._normalize_pps_state_data(data)
881
963
  return model_class.model_validate(data)
882
964
 
965
+ def _normalize_pps_state_data(self, data: dict[str, Any]) -> None:
966
+ """Normalize PPS climate values to the library's State model."""
967
+ hvac_mode = data.get("hvac_mode")
968
+ if not isinstance(hvac_mode, dict):
969
+ return
970
+
971
+ try:
972
+ raw_mode = int(hvac_mode["value"])
973
+ except (KeyError, TypeError, ValueError):
974
+ return
975
+
976
+ hvac_mode["value"] = Validation.PPS_HVAC_MODE_FROM_BSBLAN.get(
977
+ raw_mode,
978
+ raw_mode,
979
+ )
980
+
883
981
  async def state(
884
982
  self,
885
983
  include: list[str] | None = None,
@@ -901,8 +999,9 @@ class BSBLAN:
901
999
  State: The current state of the BSBLAN device.
902
1000
 
903
1001
  Note:
904
- The hvac_mode.value is returned as a raw integer from the device:
905
- 0=off, 1=auto, 2=eco, 3=heat.
1002
+ For BSB/LPB devices, hvac_mode.value is returned as a raw integer:
1003
+ 0=off, 1=auto, 2=eco, 3=heat. PPS devices normalize their raw
1004
+ operating modes to the same library values, but do not support eco.
906
1005
 
907
1006
  Example:
908
1007
  # Fetch only hvac_mode and current_temperature
@@ -974,7 +1073,8 @@ class BSBLAN:
974
1073
 
975
1074
  """
976
1075
  device_info = await self._request(base_path="/JI")
977
- return Device.model_validate(device_info)
1076
+ self._device = Device.model_validate(device_info)
1077
+ return self._device
978
1078
 
979
1079
  async def info(self, include: list[str] | None = None) -> Info:
980
1080
  """Get information about the current heating system config.
@@ -1001,6 +1101,9 @@ class BSBLAN:
1001
1101
  DeviceTime: The current time information from the BSB-LAN device.
1002
1102
 
1003
1103
  """
1104
+ await self._ensure_device_metadata()
1105
+ self._validate_time_sync_supported()
1106
+
1004
1107
  # Get only parameter 0 for time
1005
1108
  data = await self._request(params={"Parameter": "0"})
1006
1109
  # Create the data dictionary in the expected format
@@ -1018,6 +1121,8 @@ class BSBLAN:
1018
1121
  BSBLANInvalidParameterError: If the time format is invalid.
1019
1122
 
1020
1123
  """
1124
+ await self._ensure_device_metadata()
1125
+ self._validate_time_sync_supported()
1021
1126
  self._validate_time_format(time_value)
1022
1127
  state: dict[str, object] = {
1023
1128
  "Parameter": "0",
@@ -1038,7 +1143,9 @@ class BSBLAN:
1038
1143
  Args:
1039
1144
  target_temperature (str | None): The target temperature to set.
1040
1145
  hvac_mode (int | None): The HVAC mode to set as raw integer value.
1041
- Valid values: 0=off, 1=auto, 2=eco, 3=heat.
1146
+ For BSB/LPB, valid values are 0=off, 1=auto, 2=eco, 3=heat.
1147
+ For PPS, valid values are 0=off, 1=auto, and 3=heat/manual;
1148
+ they are translated to PPS raw values before posting.
1042
1149
  circuit: The heating circuit number (1 or 2). Defaults to 1.
1043
1150
 
1044
1151
  Example:
@@ -1050,6 +1157,8 @@ class BSBLAN:
1050
1157
 
1051
1158
  """
1052
1159
  self._validate_circuit(circuit)
1160
+ if self._uses_pps_bus:
1161
+ self._validate_bus_write_supported()
1053
1162
  await self._initialize_temperature_range(circuit)
1054
1163
 
1055
1164
  self._validate_single_parameter(
@@ -1082,7 +1191,7 @@ class BSBLAN:
1082
1191
  dict[str, Any]: The prepared state for the thermostat.
1083
1192
 
1084
1193
  """
1085
- param_ids = CircuitConfig.THERMOSTAT_PARAMS[circuit]
1194
+ param_ids = self._thermostat_params(circuit)
1086
1195
  state: dict[str, Any] = {}
1087
1196
  if target_temperature is not None:
1088
1197
  await self._validate_target_temperature(
@@ -1098,15 +1207,24 @@ class BSBLAN:
1098
1207
  )
1099
1208
  if hvac_mode is not None:
1100
1209
  self._validate_hvac_mode(hvac_mode)
1210
+ hvac_value = str(hvac_mode)
1211
+ if self._uses_pps_bus:
1212
+ hvac_value = Validation.PPS_HVAC_MODE_TO_BSBLAN[hvac_mode]
1101
1213
  state.update(
1102
1214
  {
1103
1215
  "Parameter": param_ids["hvac_mode"],
1104
- "Value": str(hvac_mode),
1216
+ "Value": hvac_value,
1105
1217
  "Type": "1",
1106
1218
  },
1107
1219
  )
1108
1220
  return state
1109
1221
 
1222
+ def _thermostat_params(self, circuit: int) -> dict[str, str]:
1223
+ """Return thermostat write parameters for the active bus type."""
1224
+ if self._uses_pps_bus:
1225
+ return {"target_temperature": "15004", "hvac_mode": "15000"}
1226
+ return CircuitConfig.THERMOSTAT_PARAMS[circuit]
1227
+
1110
1228
  async def _validate_target_temperature(
1111
1229
  self,
1112
1230
  target_temperature: str,
@@ -1151,13 +1269,17 @@ class BSBLAN:
1151
1269
  """Validate the HVAC mode.
1152
1270
 
1153
1271
  Args:
1154
- hvac_mode (int): The HVAC mode to validate (0-3).
1272
+ hvac_mode (int): The HVAC mode to validate. BSB/LPB accepts 0-3;
1273
+ PPS accepts 0, 1, and 3.
1155
1274
 
1156
1275
  Raises:
1157
1276
  BSBLANInvalidParameterError: If the HVAC mode is invalid.
1158
1277
 
1159
1278
  """
1160
- if hvac_mode not in Validation.HVAC_MODES:
1279
+ valid_modes = (
1280
+ Validation.PPS_HVAC_MODES if self._uses_pps_bus else Validation.HVAC_MODES
1281
+ )
1282
+ if hvac_mode not in valid_modes:
1161
1283
  raise BSBLANInvalidParameterError(str(hvac_mode))
1162
1284
 
1163
1285
  def _validate_time_format(self, time_value: str) -> None:
@@ -114,6 +114,19 @@ BASE_STATIC_VALUES_CIRCUIT2_PARAMS: Final[dict[str, str]] = {
114
114
  "1014": "min_temp",
115
115
  }
116
116
 
117
+ # PPS bus supports one room-unit style climate circuit. These parameters are
118
+ # exposed by BSB-LAN in the 15000+ range and mirror the circuit 1 climate model.
119
+ PPS_HEATING_PARAMS: Final[dict[str, str]] = {
120
+ "15000": "hvac_mode",
121
+ "15004": "target_temperature",
122
+ "15008": "current_temperature",
123
+ }
124
+
125
+ PPS_STATIC_VALUES_PARAMS: Final[dict[str, str]] = {
126
+ "15006": "min_temp",
127
+ "15007": "max_temp",
128
+ }
129
+
117
130
  V1_STATIC_VALUES_CIRCUIT2_EXTENSIONS: Final[dict[str, str]] = {
118
131
  "1030": "max_temp",
119
132
  }
@@ -207,6 +220,17 @@ class Validation:
207
220
  """Validation-related constants for BSBLAN."""
208
221
 
209
222
  HVAC_MODES: Final[set[int]] = {0, 1, 2, 3}
223
+ PPS_HVAC_MODES: Final[set[int]] = {0, 1, 3}
224
+ PPS_HVAC_MODE_TO_BSBLAN: Final[dict[int, str]] = {
225
+ 0: "2", # off
226
+ 1: "0", # auto
227
+ 3: "1", # manual/comfort
228
+ }
229
+ PPS_HVAC_MODE_FROM_BSBLAN: Final[dict[int, int]] = {
230
+ 0: 1, # auto
231
+ 1: 3, # manual/comfort
232
+ 2: 0, # off
233
+ }
210
234
  MIN_YEAR: Final[int] = 1900
211
235
  MAX_YEAR: Final[int] = 2100
212
236
 
@@ -492,6 +516,8 @@ class ErrorMsg:
492
516
  "Empty include list provided. Use None to fetch all parameters."
493
517
  )
494
518
  NO_HEATING_SCHEDULE_PARAMS = "No heating schedule parameters available"
519
+ TIME_SYNC_NOT_SUPPORTED = "Time synchronization is not supported by this device"
520
+ BUS_WRITE_NOT_SUPPORTED = "Writing parameters is not supported by this device"
495
521
 
496
522
 
497
523
  # Handle both ASCII and Unicode degree symbols
@@ -611,6 +611,26 @@ class Device(BaseModel):
611
611
  version: str
612
612
  MAC: str # pylint: disable=invalid-name
613
613
  uptime: int
614
+ bus: str | None = None
615
+ buswritable: int | bool | None = None
616
+ busaddr: int | None = None
617
+ busdest: int | None = None
618
+ busdevices: list[Any] | None = None
619
+
620
+ @property
621
+ def is_pps_bus(self) -> bool:
622
+ """Return whether the device is connected to a PPS bus."""
623
+ return self.bus is not None and self.bus.upper() == "PPS"
624
+
625
+ @property
626
+ def is_bus_writable(self) -> bool:
627
+ """Return whether BSB-LAN reports global bus writes as enabled."""
628
+ return self.buswritable is None or bool(self.buswritable)
629
+
630
+ @property
631
+ def supports_time_sync(self) -> bool:
632
+ """Return whether normal BSB/LPB time synchronization is supported."""
633
+ return not self.is_pps_bus
614
634
 
615
635
 
616
636
  class Info(BaseModel):