python-bsblan 6.1.5__tar.gz → 6.1.7__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.
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/PKG-INFO +1 -1
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/docs/api/exceptions.md +8 -0
- python_bsblan-6.1.7/openspec/changes/archive/2026-07-12-distinguish-schedule-error-types/.openspec.yaml +3 -0
- python_bsblan-6.1.7/openspec/changes/archive/2026-07-12-distinguish-schedule-error-types/design.md +101 -0
- python_bsblan-6.1.7/openspec/changes/archive/2026-07-12-distinguish-schedule-error-types/proposal.md +49 -0
- python_bsblan-6.1.7/openspec/changes/archive/2026-07-12-distinguish-schedule-error-types/specs/error-classification/spec.md +50 -0
- python_bsblan-6.1.7/openspec/changes/archive/2026-07-12-distinguish-schedule-error-types/tasks.md +28 -0
- python_bsblan-6.1.7/openspec/specs/error-classification/spec.md +59 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/package-lock.json +4 -4
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/package.json +1 -1
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/pyproject.toml +2 -2
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/__init__.py +4 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/_hot_water.py +7 -2
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/_schedules.py +4 -3
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/_transport.py +34 -4
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/exceptions.py +17 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_bsblan_edge_cases.py +12 -3
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_heating_schedule.py +9 -3
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_hot_water_additional.py +10 -4
- python_bsblan-6.1.7/tests/test_response_encoding.py +62 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.editorconfig +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.gitattributes +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/CODE_OF_CONDUCT.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/CONTRIBUTING.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/SECURITY.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/agents/pr-review.agent.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/agents/security-reviewer.agent.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/agents/tdd-refactor.agent.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/copilot-instructions.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/hooks/run-validation-after-edits.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/hooks/run_validation_after_edits.sh +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/labels.yml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/prompts/add-parameter.prompt.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/prompts/code-review.prompt.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/prompts/opsx-apply.prompt.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/prompts/opsx-archive.prompt.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/prompts/opsx-explore.prompt.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/prompts/opsx-propose.prompt.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/prompts/opsx-sync.prompt.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/release-drafter.yml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/renovate.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/skills/bsblan-parameters/SKILL.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/skills/bsblan-testing/SKILL.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/skills/feature-doc-updates/SKILL.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/skills/openspec-apply-change/SKILL.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/skills/openspec-archive-change/SKILL.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/skills/openspec-explore/SKILL.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/skills/openspec-propose/SKILL.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/skills/openspec-sync-specs/SKILL.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/auto-approve-renovate.yml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/codeql.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/dependency-review.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/docs.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/labels.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/linting.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/lock.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/pr-labels.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/release-drafter.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/release.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/scorecard.yml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/stale.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/tests.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/workflows/typing.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.github/zizmor.yml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.gitignore +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.nvmrc +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.pre-commit-config.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.prettierignore +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/.yamllint +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/AGENTS.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/CLAUDE.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/LICENSE.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/Makefile +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/README.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/docs/api/client.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/docs/api/constants.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/docs/api/models.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/docs/getting-started.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/docs/index.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/examples/control.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/examples/discovery.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/examples/fetch_param.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/examples/profile_init.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/examples/ruff.toml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/examples/speed_test.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/mkdocs.yml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-03-add-cooling-operating-mode/.openspec.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-03-add-cooling-operating-mode/design.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-03-add-cooling-operating-mode/proposal.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-03-add-cooling-operating-mode/specs/cooling-operating-mode/spec.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-03-add-cooling-operating-mode/tasks.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-harden-circuit-discovery-consistency/.openspec.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-harden-circuit-discovery-consistency/design.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-harden-circuit-discovery-consistency/proposal.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-harden-circuit-discovery-consistency/specs/circuit-discovery/spec.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-harden-circuit-discovery-consistency/tasks.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-improve-client-readability-domain-slices/.openspec.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-improve-client-readability-domain-slices/design.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-improve-client-readability-domain-slices/proposal.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-improve-client-readability-domain-slices/specs/client-retry-policy/spec.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/changes/archive/2026-07-04-improve-client-readability-domain-slices/tasks.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/config.yaml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/specs/circuit-discovery/spec.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/specs/client-retry-policy/spec.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/openspec/specs/cooling-operating-mode/spec.md +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/sonar-project.properties +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/_parameters.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/_temperature.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/_thermostat.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/_validation.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/_version.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/bsblan.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/constants.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/models.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/py.typed +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/src/bsblan/utility.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/__init__.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/conftest.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/device.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/dict_version.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/hot_water_state.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/info.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/password.txt +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/pps_device.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/pps_state.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/pps_static_values.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/sensor.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/state.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/state_circuit2.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/static_state.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/static_state_circuit2.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/thermostat_hvac.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/thermostat_temp.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/fixtures/time.json +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/ruff.toml +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_api_initialization.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_api_validation.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_auth.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_backoff_retry.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_bsblan.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_circuit.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_configuration.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_constants.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_context_manager.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_device.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_dhw_time_switch.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_entity_info.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_entity_info_ha.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_hotwater_state.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_include_parameter.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_info.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_initialization.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_pps.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_read_parameters.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_reset_validation.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_schedule_models.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_sensor.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_set_heating_schedule.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_set_hot_water_schedule.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_set_hotwater.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_state.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_static_state.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_temperature_unit.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_temperature_validation.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_thermostat.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_time.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_utility.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_utility_additional.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_utility_edge_cases.py +0 -0
- {python_bsblan-6.1.5 → python_bsblan-6.1.7}/tests/test_version_errors.py +0 -0
|
@@ -13,3 +13,11 @@ Custom exceptions raised by the library.
|
|
|
13
13
|
## BSBLANAuthError
|
|
14
14
|
|
|
15
15
|
::: bsblan.BSBLANAuthError
|
|
16
|
+
|
|
17
|
+
## BSBLANUnsupportedFeatureError
|
|
18
|
+
|
|
19
|
+
::: bsblan.BSBLANUnsupportedFeatureError
|
|
20
|
+
|
|
21
|
+
## BSBLANMalformedResponseError
|
|
22
|
+
|
|
23
|
+
::: bsblan.BSBLANMalformedResponseError
|
python_bsblan-6.1.7/openspec/changes/archive/2026-07-12-distinguish-schedule-error-types/design.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
## Context
|
|
2
|
+
|
|
3
|
+
`python-bsblan` currently exposes a single generic `BSBLANError` for a wide
|
|
4
|
+
range of failure modes. Two of them are semantically opposite from a retry
|
|
5
|
+
standpoint:
|
|
6
|
+
|
|
7
|
+
- **Unsupported schedule** — the device does not expose the requested
|
|
8
|
+
time-program parameters. In `_schedules.heating_schedule()` this raises
|
|
9
|
+
`BSBLANError(ErrorMsg.NO_HEATING_SCHEDULE_PARAMS)`, and in
|
|
10
|
+
`_hot_water._get_filtered_data()` it raises `BSBLANError(error_msg)` for the
|
|
11
|
+
hot-water schedule group. This is a **permanent** condition.
|
|
12
|
+
- **Malformed response** — the transport cannot decode or JSON-parse the body.
|
|
13
|
+
In `_transport.request_with_retry()` this is caught from `ValueError` /
|
|
14
|
+
`UnicodeDecodeError` and re-raised as
|
|
15
|
+
`BSBLANError(ErrorMsg.INVALID_RESPONSE.format(e))`. This is a **transient**
|
|
16
|
+
condition.
|
|
17
|
+
|
|
18
|
+
Because both surface as `BSBLANError`, a downstream integration (Home Assistant)
|
|
19
|
+
cannot decide whether to retry. It must either stop retrying (losing recovery
|
|
20
|
+
from transient malformed responses) or retry forever (uselessly hammering a
|
|
21
|
+
device that will never support a schedule after a write).
|
|
22
|
+
|
|
23
|
+
The library's own `backoff` decorator only retries `TimeoutError` and
|
|
24
|
+
`aiohttp.ClientError`, so `BSBLANError` is never retried inside the library; the
|
|
25
|
+
retry decision lives in the consumer. The fix is therefore an additive exception
|
|
26
|
+
taxonomy, not a change to the transport retry policy.
|
|
27
|
+
|
|
28
|
+
## Goals / Non-Goals
|
|
29
|
+
|
|
30
|
+
**Goals:**
|
|
31
|
+
- Let callers distinguish permanent unsupported-feature failures from transient
|
|
32
|
+
malformed-response failures by `except` type.
|
|
33
|
+
- Keep the change fully backward compatible with existing `except BSBLANError`
|
|
34
|
+
handlers.
|
|
35
|
+
- Reuse existing `ErrorMsg` strings; no message wording changes.
|
|
36
|
+
|
|
37
|
+
**Non-Goals:**
|
|
38
|
+
- Changing the transport-level `backoff` retry policy or which HTTP statuses are
|
|
39
|
+
retried (`client-retry-policy` is unchanged).
|
|
40
|
+
- Adding automatic retry logic inside the library for schedules.
|
|
41
|
+
- Reclassifying other existing `BSBLANError` raise sites beyond schedule reads
|
|
42
|
+
and malformed-response parsing.
|
|
43
|
+
|
|
44
|
+
## Decisions
|
|
45
|
+
|
|
46
|
+
**Decision: Two new subclasses of `BSBLANError`.**
|
|
47
|
+
Add `BSBLANUnsupportedFeatureError` (permanent) and
|
|
48
|
+
`BSBLANMalformedResponseError` (transient), both subclassing `BSBLANError`.
|
|
49
|
+
Subclassing preserves backward compatibility: any existing `except BSBLANError`
|
|
50
|
+
keeps catching them.
|
|
51
|
+
- *Alternative considered:* a single new error with a `retryable: bool`
|
|
52
|
+
attribute. Rejected — attribute inspection is less idiomatic than `except`
|
|
53
|
+
type matching and easier to get wrong at call sites.
|
|
54
|
+
- *Alternative considered:* schedule-specific name (e.g.
|
|
55
|
+
`BSBLANUnsupportedScheduleError`). Chose the more general
|
|
56
|
+
`BSBLANUnsupportedFeatureError` because the same permanent semantics apply to
|
|
57
|
+
both heating and hot-water schedule reads and can extend to other
|
|
58
|
+
unsupported-parameter cases later.
|
|
59
|
+
|
|
60
|
+
**Decision: Raise the permanent error at the schedule-read guard sites.**
|
|
61
|
+
Replace the bare `BSBLANError(...)` at
|
|
62
|
+
`_schedules.heating_schedule()` (no mapped data) and the hot-water schedule
|
|
63
|
+
branch in `_hot_water._get_filtered_data()` with
|
|
64
|
+
`BSBLANUnsupportedFeatureError`, keeping the existing `ErrorMsg` text. The
|
|
65
|
+
hot-water helper is shared by state/config/schedule reads, so scope the new
|
|
66
|
+
error to the schedule group only (via `group_name == "schedule"`) to avoid
|
|
67
|
+
reclassifying unrelated state/config failures.
|
|
68
|
+
|
|
69
|
+
**Decision: Raise the transient error at the transport parse site.**
|
|
70
|
+
In `_transport.request_with_retry()`, replace the
|
|
71
|
+
`raise BSBLANError(msg)` in the `(ValueError, UnicodeDecodeError)` handler with
|
|
72
|
+
`raise BSBLANMalformedResponseError(msg)`, reusing
|
|
73
|
+
`ErrorMsg.INVALID_RESPONSE`.
|
|
74
|
+
|
|
75
|
+
**Decision: Export the new exceptions from the package.**
|
|
76
|
+
Add both classes to `bsblan.exceptions` and to the package `__all__` /
|
|
77
|
+
re-exports so consumers can import them from the top-level `bsblan` namespace.
|
|
78
|
+
|
|
79
|
+
## Risks / Trade-offs
|
|
80
|
+
|
|
81
|
+
- **[Consumers catching the narrow generic message string]** → Mitigation:
|
|
82
|
+
messages are unchanged; only the concrete type is narrower. Broad
|
|
83
|
+
`except BSBLANError` still works.
|
|
84
|
+
- **[Over-scoping the hot-water helper]** → Mitigation: gate the new error on the
|
|
85
|
+
schedule group so state/config reads keep raising the existing generic error
|
|
86
|
+
and existing tests stay green.
|
|
87
|
+
- **[Missed raise site]** → Mitigation: grep for `NO_HEATING_SCHEDULE_PARAMS`,
|
|
88
|
+
the hot-water schedule `error_msg`, and `INVALID_RESPONSE` to confirm all
|
|
89
|
+
targeted sites are updated; add tests asserting the concrete types.
|
|
90
|
+
|
|
91
|
+
## Migration Plan
|
|
92
|
+
|
|
93
|
+
Additive, no migration required. Release as a minor version. Consumers may
|
|
94
|
+
optionally add narrower `except BSBLANUnsupportedFeatureError` /
|
|
95
|
+
`except BSBLANMalformedResponseError` handlers; existing code is unaffected.
|
|
96
|
+
|
|
97
|
+
## Open Questions
|
|
98
|
+
|
|
99
|
+
- Final class names: `BSBLANUnsupportedFeatureError` vs
|
|
100
|
+
`BSBLANUnsupportedScheduleError`. Proposed the broader name; confirm during
|
|
101
|
+
review.
|
python_bsblan-6.1.7/openspec/changes/archive/2026-07-12-distinguish-schedule-error-types/proposal.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
## Why
|
|
2
|
+
|
|
3
|
+
In python-bsblan 6.1.6 both an *unsupported schedule* (a device that will never
|
|
4
|
+
return the requested time-program parameters) and a *malformed response* (a
|
|
5
|
+
transient decode/parse failure) are surfaced as the same generic `BSBLANError`.
|
|
6
|
+
Downstream integrations such as Home Assistant cannot tell them apart by
|
|
7
|
+
exception type, so they must choose between stopping retries on a transient
|
|
8
|
+
malformed response or retrying an unsupported schedule indefinitely after a
|
|
9
|
+
write. A dedicated exception taxonomy is needed at the library level to make the
|
|
10
|
+
permanent-vs-transient distinction explicit.
|
|
11
|
+
|
|
12
|
+
## What Changes
|
|
13
|
+
|
|
14
|
+
- Add a dedicated **permanent** exception `BSBLANUnsupportedFeatureError`
|
|
15
|
+
(subclass of `BSBLANError`) raised when a device does not expose the requested
|
|
16
|
+
schedule parameters, so callers know retrying will never succeed.
|
|
17
|
+
- Add a dedicated **transient** exception `BSBLANMalformedResponseError`
|
|
18
|
+
(subclass of `BSBLANError`) raised when a response body cannot be decoded or
|
|
19
|
+
parsed as valid JSON, signalling that a retry may succeed.
|
|
20
|
+
- Raise `BSBLANUnsupportedFeatureError` in the heating- and hot-water-schedule
|
|
21
|
+
read paths where a bare `BSBLANError` is currently raised for absent schedule
|
|
22
|
+
parameters.
|
|
23
|
+
- Raise `BSBLANMalformedResponseError` in the transport layer where invalid
|
|
24
|
+
JSON / decode errors are currently wrapped in a bare `BSBLANError`.
|
|
25
|
+
- Both new exceptions subclass `BSBLANError`, so existing `except BSBLANError`
|
|
26
|
+
handlers keep working (non-breaking).
|
|
27
|
+
|
|
28
|
+
## Capabilities
|
|
29
|
+
|
|
30
|
+
### New Capabilities
|
|
31
|
+
- `error-classification`: A library exception taxonomy that distinguishes
|
|
32
|
+
permanent unsupported-feature failures from transient malformed-response
|
|
33
|
+
failures, letting callers make correct retry decisions.
|
|
34
|
+
|
|
35
|
+
### Modified Capabilities
|
|
36
|
+
<!-- No existing spec's requirements change; client-retry-policy behavior is unaffected. -->
|
|
37
|
+
|
|
38
|
+
## Impact
|
|
39
|
+
|
|
40
|
+
- `src/bsblan/exceptions.py`: two new exception classes.
|
|
41
|
+
- `src/bsblan/_schedules.py` and `src/bsblan/_hot_water.py`: raise the new
|
|
42
|
+
unsupported-feature error for absent schedule parameters.
|
|
43
|
+
- `src/bsblan/_transport.py`: raise the new malformed-response error for
|
|
44
|
+
decode/parse failures.
|
|
45
|
+
- `src/bsblan/constants.py`: no new messages required; existing `ErrorMsg`
|
|
46
|
+
entries are reused.
|
|
47
|
+
- `tests/`: new coverage asserting the specific exception types are raised.
|
|
48
|
+
- Public API: additive only. Consumers relying on `BSBLANError` are unaffected;
|
|
49
|
+
consumers can opt into finer-grained handling.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
## ADDED Requirements
|
|
2
|
+
|
|
3
|
+
### Requirement: Unsupported schedules raise a permanent error
|
|
4
|
+
|
|
5
|
+
The client SHALL raise `BSBLANUnsupportedFeatureError` (a subclass of
|
|
6
|
+
`BSBLANError`) when a schedule read cannot proceed because the device exposes no
|
|
7
|
+
schedule parameters for the requested feature. This error MUST signal a
|
|
8
|
+
permanent condition so callers know that retrying the same request will never
|
|
9
|
+
succeed.
|
|
10
|
+
|
|
11
|
+
#### Scenario: Heating schedule parameters absent
|
|
12
|
+
|
|
13
|
+
- **WHEN** `heating_schedule()` is called for a circuit whose time-program
|
|
14
|
+
parameters are not present in the device response (no schedule parameters can
|
|
15
|
+
be mapped)
|
|
16
|
+
- **THEN** the client raises `BSBLANUnsupportedFeatureError`
|
|
17
|
+
- **AND** the raised exception is also an instance of `BSBLANError`
|
|
18
|
+
|
|
19
|
+
#### Scenario: Hot water schedule parameters absent
|
|
20
|
+
|
|
21
|
+
- **WHEN** `schedule()` is called on a device that exposes no hot-water
|
|
22
|
+
time-program parameters after include filtering
|
|
23
|
+
- **THEN** the client raises `BSBLANUnsupportedFeatureError`
|
|
24
|
+
- **AND** the raised exception is also an instance of `BSBLANError`
|
|
25
|
+
|
|
26
|
+
### Requirement: Malformed responses raise a transient error
|
|
27
|
+
|
|
28
|
+
The client SHALL raise `BSBLANMalformedResponseError` (a subclass of
|
|
29
|
+
`BSBLANError`) when a device response body cannot be decoded or parsed as valid
|
|
30
|
+
JSON. This error MUST signal a transient condition so callers know that a retry
|
|
31
|
+
may succeed.
|
|
32
|
+
|
|
33
|
+
#### Scenario: Response body is not valid JSON
|
|
34
|
+
|
|
35
|
+
- **WHEN** a request receives an HTTP `200` response whose body cannot be parsed
|
|
36
|
+
as JSON
|
|
37
|
+
- **THEN** the client raises `BSBLANMalformedResponseError`
|
|
38
|
+
- **AND** the raised exception is also an instance of `BSBLANError`
|
|
39
|
+
|
|
40
|
+
### Requirement: New exceptions preserve backward compatibility
|
|
41
|
+
|
|
42
|
+
Both `BSBLANUnsupportedFeatureError` and `BSBLANMalformedResponseError` SHALL
|
|
43
|
+
subclass `BSBLANError` so that existing `except BSBLANError` handlers continue to
|
|
44
|
+
catch them without modification.
|
|
45
|
+
|
|
46
|
+
#### Scenario: Existing broad handler still catches new errors
|
|
47
|
+
|
|
48
|
+
- **WHEN** code catches `BSBLANError` around a call that raises either
|
|
49
|
+
`BSBLANUnsupportedFeatureError` or `BSBLANMalformedResponseError`
|
|
50
|
+
- **THEN** the exception is caught by the `BSBLANError` handler
|
python_bsblan-6.1.7/openspec/changes/archive/2026-07-12-distinguish-schedule-error-types/tasks.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
## 1. Exceptions
|
|
2
|
+
|
|
3
|
+
- [x] 1.1 Add `BSBLANUnsupportedFeatureError(BSBLANError)` to `src/bsblan/exceptions.py` with a docstring describing it as a permanent (non-retryable) condition.
|
|
4
|
+
- [x] 1.2 Add `BSBLANMalformedResponseError(BSBLANError)` to `src/bsblan/exceptions.py` with a docstring describing it as a transient (retryable) condition.
|
|
5
|
+
- [x] 1.3 Import and re-export both new exceptions in `src/bsblan/__init__.py` and add them to `__all__`.
|
|
6
|
+
|
|
7
|
+
## 2. Schedule read paths (permanent error)
|
|
8
|
+
|
|
9
|
+
- [x] 2.1 In `src/bsblan/_schedules.py` `heating_schedule()`, raise `BSBLANUnsupportedFeatureError(ErrorMsg.NO_HEATING_SCHEDULE_PARAMS)` when no schedule parameters are mapped (replace the bare `BSBLANError`).
|
|
10
|
+
- [x] 2.2 In `src/bsblan/_hot_water.py` `fetch_data()`, raise `BSBLANUnsupportedFeatureError(error_msg)` when the schedule group (`group_name == "schedule"`) has no filtered params, keeping other groups on the existing generic error.
|
|
11
|
+
- [x] 2.3 Update the `Raises:` docstrings on the affected schedule methods to reference the new exception type.
|
|
12
|
+
|
|
13
|
+
## 3. Transport parse path (transient error)
|
|
14
|
+
|
|
15
|
+
- [x] 3.1 In `src/bsblan/_transport.py` `request_with_retry()`, raise `BSBLANMalformedResponseError(msg)` from the `(ValueError, UnicodeDecodeError)` handler (replace the bare `BSBLANError`), reusing `ErrorMsg.INVALID_RESPONSE`.
|
|
16
|
+
- [x] 3.2 Update the `Raises:` docstring on `request_with_retry()` to reference the new exception type.
|
|
17
|
+
|
|
18
|
+
## 4. Tests
|
|
19
|
+
|
|
20
|
+
- [x] 4.1 Add a test asserting `heating_schedule()` raises `BSBLANUnsupportedFeatureError` when schedule params are absent, and that it is also a `BSBLANError`.
|
|
21
|
+
- [x] 4.2 Add a test asserting hot-water `schedule()` raises `BSBLANUnsupportedFeatureError` when schedule params are absent.
|
|
22
|
+
- [x] 4.3 Add a test asserting a non-JSON / undecodable response raises `BSBLANMalformedResponseError` and that it is also a `BSBLANError`.
|
|
23
|
+
- [x] 4.4 Verify existing state/config hot-water tests still raise the generic `BSBLANError` (no regression from the group gating).
|
|
24
|
+
|
|
25
|
+
## 5. Documentation & validation
|
|
26
|
+
|
|
27
|
+
- [x] 5.1 Document the two new exceptions in `docs/api/exceptions.md`.
|
|
28
|
+
- [x] 5.2 Run `uv run prek run --all-files` and `uv run pytest --cov=src/bsblan --cov-report=term-missing`; ensure total coverage stays >= 95% and patch coverage is 100%.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# error-classification
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
A library exception taxonomy that distinguishes permanent unsupported-feature
|
|
6
|
+
failures from transient malformed-response failures, letting callers make
|
|
7
|
+
correct retry decisions. Both new exceptions subclass `BSBLANError` so existing
|
|
8
|
+
broad handlers keep working.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
### Requirement: Unsupported schedules raise a permanent error
|
|
13
|
+
|
|
14
|
+
The client SHALL raise `BSBLANUnsupportedFeatureError` (a subclass of
|
|
15
|
+
`BSBLANError`) when a schedule read cannot proceed because the device exposes no
|
|
16
|
+
schedule parameters for the requested feature. This error MUST signal a
|
|
17
|
+
permanent condition so callers know that retrying the same request will never
|
|
18
|
+
succeed.
|
|
19
|
+
|
|
20
|
+
#### Scenario: Heating schedule parameters absent
|
|
21
|
+
|
|
22
|
+
- **WHEN** `heating_schedule()` is called for a circuit whose time-program
|
|
23
|
+
parameters are not present in the device response (no schedule parameters can
|
|
24
|
+
be mapped)
|
|
25
|
+
- **THEN** the client raises `BSBLANUnsupportedFeatureError`
|
|
26
|
+
- **AND** the raised exception is also an instance of `BSBLANError`
|
|
27
|
+
|
|
28
|
+
#### Scenario: Hot water schedule parameters absent
|
|
29
|
+
|
|
30
|
+
- **WHEN** `schedule()` is called on a device that exposes no hot-water
|
|
31
|
+
time-program parameters after include filtering
|
|
32
|
+
- **THEN** the client raises `BSBLANUnsupportedFeatureError`
|
|
33
|
+
- **AND** the raised exception is also an instance of `BSBLANError`
|
|
34
|
+
|
|
35
|
+
### Requirement: Malformed responses raise a transient error
|
|
36
|
+
|
|
37
|
+
The client SHALL raise `BSBLANMalformedResponseError` (a subclass of
|
|
38
|
+
`BSBLANError`) when a device response body cannot be decoded or parsed as valid
|
|
39
|
+
JSON. This error MUST signal a transient condition so callers know that a retry
|
|
40
|
+
may succeed.
|
|
41
|
+
|
|
42
|
+
#### Scenario: Response body is not valid JSON
|
|
43
|
+
|
|
44
|
+
- **WHEN** a request receives an HTTP `200` response whose body cannot be parsed
|
|
45
|
+
as JSON
|
|
46
|
+
- **THEN** the client raises `BSBLANMalformedResponseError`
|
|
47
|
+
- **AND** the raised exception is also an instance of `BSBLANError`
|
|
48
|
+
|
|
49
|
+
### Requirement: New exceptions preserve backward compatibility
|
|
50
|
+
|
|
51
|
+
Both `BSBLANUnsupportedFeatureError` and `BSBLANMalformedResponseError` SHALL
|
|
52
|
+
subclass `BSBLANError` so that existing `except BSBLANError` handlers continue to
|
|
53
|
+
catch them without modification.
|
|
54
|
+
|
|
55
|
+
#### Scenario: Existing broad handler still catches new errors
|
|
56
|
+
|
|
57
|
+
- **WHEN** code catches `BSBLANError` around a call that raises either
|
|
58
|
+
`BSBLANUnsupportedFeatureError` or `BSBLANMalformedResponseError`
|
|
59
|
+
- **THEN** the exception is caught by the `BSBLANError` handler
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"url-parse": "1.5.10"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"prettier": "3.9.
|
|
15
|
+
"prettier": "3.9.5"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"node_modules/prettier": {
|
|
19
|
-
"version": "3.9.
|
|
20
|
-
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.
|
|
21
|
-
"integrity": "sha512
|
|
19
|
+
"version": "3.9.5",
|
|
20
|
+
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
|
|
21
|
+
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
|
22
22
|
"dev": true,
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"bin": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-bsblan"
|
|
3
|
-
version = "6.1.
|
|
3
|
+
version = "6.1.7"
|
|
4
4
|
description = "Asynchronous Python client for BSBLAN API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Willem-Jan van Rootselaar", email = "liudgervr@gmail.com"}
|
|
@@ -196,7 +196,7 @@ dev = [
|
|
|
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.
|
|
199
|
+
"ty==0.0.56",
|
|
200
200
|
"prek>=0.3.3",
|
|
201
201
|
"pre-commit-hooks==6.0.0",
|
|
202
202
|
"pylint==4.0.5",
|
|
@@ -12,6 +12,8 @@ from .exceptions import (
|
|
|
12
12
|
BSBLANAuthError,
|
|
13
13
|
BSBLANConnectionError,
|
|
14
14
|
BSBLANError,
|
|
15
|
+
BSBLANMalformedResponseError,
|
|
16
|
+
BSBLANUnsupportedFeatureError,
|
|
15
17
|
BSBLANVersionError,
|
|
16
18
|
)
|
|
17
19
|
from .models import (
|
|
@@ -45,6 +47,8 @@ __all__ = [
|
|
|
45
47
|
"BSBLANConfig",
|
|
46
48
|
"BSBLANConnectionError",
|
|
47
49
|
"BSBLANError",
|
|
50
|
+
"BSBLANMalformedResponseError",
|
|
51
|
+
"BSBLANUnsupportedFeatureError",
|
|
48
52
|
"BSBLANVersionError",
|
|
49
53
|
"DHWSchedule",
|
|
50
54
|
"DHWTimeSwitchPrograms",
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any, TypeVar
|
|
6
6
|
|
|
7
7
|
from .constants import ErrorMsg, HotWaterParams
|
|
8
|
-
from .exceptions import BSBLANError
|
|
8
|
+
from .exceptions import BSBLANError, BSBLANUnsupportedFeatureError
|
|
9
9
|
from .models import (
|
|
10
10
|
HotWaterConfig,
|
|
11
11
|
HotWaterSchedule,
|
|
@@ -87,7 +87,10 @@ class HotWaterManager:
|
|
|
87
87
|
The populated model instance.
|
|
88
88
|
|
|
89
89
|
Raises:
|
|
90
|
-
|
|
90
|
+
BSBLANUnsupportedFeatureError: If the schedule group exposes no
|
|
91
|
+
parameters (a permanent condition).
|
|
92
|
+
BSBLANError: If no parameters are available for a non-schedule
|
|
93
|
+
group.
|
|
91
94
|
|
|
92
95
|
"""
|
|
93
96
|
# Granular lazy load: validate only this param group on first access
|
|
@@ -105,6 +108,8 @@ class HotWaterManager:
|
|
|
105
108
|
filtered_params = self._apply_include_filter(filtered_params, include)
|
|
106
109
|
|
|
107
110
|
if not filtered_params:
|
|
111
|
+
if group_name == "schedule":
|
|
112
|
+
raise BSBLANUnsupportedFeatureError(error_msg)
|
|
108
113
|
raise BSBLANError(error_msg)
|
|
109
114
|
|
|
110
115
|
data = await self._request_named_params(filtered_params)
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any
|
|
6
6
|
|
|
7
7
|
from .constants import ErrorMsg, HeatingScheduleParams, HotWaterParams
|
|
8
|
-
from .exceptions import BSBLANError
|
|
8
|
+
from .exceptions import BSBLANError, BSBLANUnsupportedFeatureError
|
|
9
9
|
from .models import HeatingTimeSwitchPrograms
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
@@ -62,7 +62,8 @@ class ScheduleManager:
|
|
|
62
62
|
HeatingTimeSwitchPrograms: Heating schedule information.
|
|
63
63
|
|
|
64
64
|
Raises:
|
|
65
|
-
|
|
65
|
+
BSBLANUnsupportedFeatureError: If the device exposes no heating
|
|
66
|
+
schedule parameters (a permanent condition).
|
|
66
67
|
|
|
67
68
|
"""
|
|
68
69
|
self._validate_circuit(circuit)
|
|
@@ -79,7 +80,7 @@ class ScheduleManager:
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
if not mapped_data:
|
|
82
|
-
raise
|
|
83
|
+
raise BSBLANUnsupportedFeatureError(ErrorMsg.NO_HEATING_SCHEDULE_PARAMS)
|
|
83
84
|
|
|
84
85
|
return HeatingTimeSwitchPrograms.model_validate(mapped_data)
|
|
85
86
|
|
|
@@ -9,6 +9,7 @@ stable ``_request`` facade that delegates here.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
|
+
import json
|
|
12
13
|
import logging
|
|
13
14
|
from typing import TYPE_CHECKING, Any, cast
|
|
14
15
|
|
|
@@ -19,7 +20,7 @@ from packaging import version as pkg_version
|
|
|
19
20
|
from yarl import URL
|
|
20
21
|
|
|
21
22
|
from .constants import ErrorMsg
|
|
22
|
-
from .exceptions import BSBLANAuthError, BSBLANError
|
|
23
|
+
from .exceptions import BSBLANAuthError, BSBLANError, BSBLANMalformedResponseError
|
|
23
24
|
|
|
24
25
|
if TYPE_CHECKING:
|
|
25
26
|
from collections.abc import Callable, Mapping
|
|
@@ -109,8 +110,10 @@ class BSBLANTransport:
|
|
|
109
110
|
dict[str, Any]: The JSON response from the BSBLAN device.
|
|
110
111
|
|
|
111
112
|
Raises:
|
|
112
|
-
BSBLANError: If the session is missing
|
|
113
|
+
BSBLANError: If the session is missing.
|
|
113
114
|
BSBLANAuthError: If authentication fails (401/403, not retried).
|
|
115
|
+
BSBLANMalformedResponseError: If the response body cannot be
|
|
116
|
+
decoded or parsed as JSON (a transient condition).
|
|
114
117
|
|
|
115
118
|
"""
|
|
116
119
|
session = self._session_getter()
|
|
@@ -131,7 +134,7 @@ class BSBLANTransport:
|
|
|
131
134
|
headers=headers,
|
|
132
135
|
) as response:
|
|
133
136
|
response.raise_for_status()
|
|
134
|
-
response_data =
|
|
137
|
+
response_data = await self._read_json(response)
|
|
135
138
|
return self._process_response(response_data, base_path)
|
|
136
139
|
except aiohttp.ClientResponseError as e:
|
|
137
140
|
if e.status in HTTP_AUTH_STATUSES:
|
|
@@ -140,7 +143,34 @@ class BSBLANTransport:
|
|
|
140
143
|
except (ValueError, UnicodeDecodeError) as e:
|
|
141
144
|
# Handle JSON decode errors and other parsing issues
|
|
142
145
|
msg = ErrorMsg.INVALID_RESPONSE.format(e)
|
|
143
|
-
raise
|
|
146
|
+
raise BSBLANMalformedResponseError(msg) from e
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
async def _read_json(response: aiohttp.ClientResponse) -> dict[str, Any]:
|
|
150
|
+
"""Decode a BSB-LAN JSON response tolerant of non-UTF-8 encodings.
|
|
151
|
+
|
|
152
|
+
Some BSB-LAN firmwares serve custom parameter descriptions using
|
|
153
|
+
Latin-1 (ISO-8859-1) bytes (for example the ``§`` or ``°`` characters)
|
|
154
|
+
while declaring no charset. ``aiohttp`` assumes UTF-8 and raises
|
|
155
|
+
``UnicodeDecodeError``. Read the raw body and fall back to Latin-1,
|
|
156
|
+
which can decode any byte sequence, before parsing the JSON.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
response: The active aiohttp response to read.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
dict[str, Any]: The parsed JSON payload.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
ValueError: If the body is not valid JSON.
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
raw = await response.read()
|
|
169
|
+
try:
|
|
170
|
+
text = raw.decode("utf-8")
|
|
171
|
+
except UnicodeDecodeError:
|
|
172
|
+
text = raw.decode("latin-1")
|
|
173
|
+
return cast("dict[str, Any]", json.loads(text))
|
|
144
174
|
|
|
145
175
|
def _process_response(
|
|
146
176
|
self, response_data: dict[str, Any], base_path: str
|
|
@@ -77,3 +77,20 @@ class BSBLANAuthError(BSBLANError):
|
|
|
77
77
|
"""Raised when authentication fails."""
|
|
78
78
|
|
|
79
79
|
message: str = "Authentication failed. Please check your username and password."
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BSBLANUnsupportedFeatureError(BSBLANError):
|
|
83
|
+
"""Raised when the device does not support a requested feature.
|
|
84
|
+
|
|
85
|
+
This signals a permanent condition: the device does not expose the
|
|
86
|
+
requested parameters (for example a heating or hot water schedule), so
|
|
87
|
+
retrying the same request will never succeed.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BSBLANMalformedResponseError(BSBLANError):
|
|
92
|
+
"""Raised when a device response cannot be decoded or parsed.
|
|
93
|
+
|
|
94
|
+
This signals a transient condition: the response body could not be decoded
|
|
95
|
+
or parsed as valid JSON, so a retry may succeed.
|
|
96
|
+
"""
|
|
@@ -9,7 +9,11 @@ import aiohttp
|
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
11
|
from bsblan import BSBLAN, BSBLANConfig
|
|
12
|
-
from bsblan.exceptions import
|
|
12
|
+
from bsblan.exceptions import (
|
|
13
|
+
BSBLANConnectionError,
|
|
14
|
+
BSBLANError,
|
|
15
|
+
BSBLANMalformedResponseError,
|
|
16
|
+
)
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
@pytest.mark.asyncio
|
|
@@ -71,15 +75,20 @@ async def test_value_error_path(monkeypatch: Any) -> None:
|
|
|
71
75
|
# Mock a successful response but with invalid JSON processing
|
|
72
76
|
mock_response = MagicMock()
|
|
73
77
|
mock_response.raise_for_status = MagicMock()
|
|
74
|
-
mock_response.
|
|
78
|
+
mock_response.read = AsyncMock(return_value=b"Invalid JSON")
|
|
75
79
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
|
76
80
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
|
77
81
|
|
|
78
82
|
monkeypatch.setattr(session, "request", MagicMock(return_value=mock_response))
|
|
79
83
|
|
|
80
|
-
with pytest.raises(
|
|
84
|
+
with pytest.raises(
|
|
85
|
+
BSBLANMalformedResponseError, match="Invalid response format"
|
|
86
|
+
) as exc_info:
|
|
81
87
|
await bsblan._request()
|
|
82
88
|
|
|
89
|
+
# Transient error must remain catchable as the generic BSBLANError.
|
|
90
|
+
assert isinstance(exc_info.value, BSBLANError)
|
|
91
|
+
|
|
83
92
|
|
|
84
93
|
def test_bsblan_config_initialization_edge_cases() -> None:
|
|
85
94
|
"""Test edge cases in BSBLAN initialization."""
|
|
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock
|
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
|
-
from bsblan.exceptions import BSBLANError
|
|
10
|
+
from bsblan.exceptions import BSBLANError, BSBLANUnsupportedFeatureError
|
|
11
11
|
from bsblan.models import HeatingTimeSwitchPrograms
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
@@ -138,8 +138,14 @@ async def test_heating_schedule_invalid_include_raises(mock_bsblan: BSBLAN) -> N
|
|
|
138
138
|
|
|
139
139
|
@pytest.mark.asyncio
|
|
140
140
|
async def test_heating_schedule_no_params_raises(mock_bsblan: BSBLAN) -> None:
|
|
141
|
-
"""Test no schedule params in response raises error."""
|
|
141
|
+
"""Test no schedule params in response raises unsupported-feature error."""
|
|
142
142
|
mock_bsblan._request = AsyncMock(return_value={}) # type: ignore[method-assign]
|
|
143
143
|
|
|
144
|
-
with pytest.raises(
|
|
144
|
+
with pytest.raises(
|
|
145
|
+
BSBLANUnsupportedFeatureError,
|
|
146
|
+
match="No heating schedule parameters available",
|
|
147
|
+
) as exc_info:
|
|
145
148
|
await mock_bsblan.heating_schedule(circuit=1)
|
|
149
|
+
|
|
150
|
+
# Permanent error must remain catchable as the generic BSBLANError.
|
|
151
|
+
assert isinstance(exc_info.value, BSBLANError)
|
|
@@ -12,7 +12,7 @@ import pytest
|
|
|
12
12
|
|
|
13
13
|
from bsblan import BSBLAN, BSBLANConfig
|
|
14
14
|
from bsblan.constants import API_FULL
|
|
15
|
-
from bsblan.exceptions import BSBLANError
|
|
15
|
+
from bsblan.exceptions import BSBLANError, BSBLANUnsupportedFeatureError
|
|
16
16
|
from bsblan.models import HotWaterConfig, HotWaterSchedule
|
|
17
17
|
from bsblan.utility import APIValidator
|
|
18
18
|
from tests import load_fixture
|
|
@@ -253,11 +253,14 @@ async def test_hot_water_schedule_no_params_error(
|
|
|
253
253
|
bsblan._validator._validated_hot_water_groups.add("schedule")
|
|
254
254
|
|
|
255
255
|
with pytest.raises(
|
|
256
|
-
|
|
256
|
+
BSBLANUnsupportedFeatureError,
|
|
257
257
|
match="No hot water schedule parameters available",
|
|
258
|
-
):
|
|
258
|
+
) as exc_info:
|
|
259
259
|
await bsblan.hot_water_schedule()
|
|
260
260
|
|
|
261
|
+
# Permanent error must remain catchable as the generic BSBLANError.
|
|
262
|
+
assert isinstance(exc_info.value, BSBLANError)
|
|
263
|
+
|
|
261
264
|
|
|
262
265
|
@pytest.mark.asyncio
|
|
263
266
|
async def test_hot_water_state_no_params_error(
|
|
@@ -285,9 +288,12 @@ async def test_hot_water_state_no_params_error(
|
|
|
285
288
|
with pytest.raises(
|
|
286
289
|
BSBLANError,
|
|
287
290
|
match="No essential hot water parameters available",
|
|
288
|
-
):
|
|
291
|
+
) as exc_info:
|
|
289
292
|
await bsblan.hot_water_state()
|
|
290
293
|
|
|
294
|
+
# State group must NOT be reclassified as an unsupported-feature error.
|
|
295
|
+
assert not isinstance(exc_info.value, BSBLANUnsupportedFeatureError)
|
|
296
|
+
|
|
291
297
|
|
|
292
298
|
@pytest.mark.asyncio
|
|
293
299
|
async def test_granular_hot_water_validation(
|