Skip to content

Commit cfd6bf9

Browse files
authored
fix: four defects found by the portfolio audit (serial leak, dead entity gate, solar wipe, Off revert) (#35)
Security — diagnostics leaked the appliance serial four times over. POINTTAPI writes it to address, device_id and uuid (config_flow), none of which were in TO_REDACT_CONFIG; and _redact_path_response looked for a top-level "uuid" key that real {"id": ..., "value": ...} responses never carry, so /gateway/uuid's value went out in the clear too. The existing test only exercised the key shape that never occurs in live data, which is why this read as covered. Local-path entities were all disabled by default. Five platforms gated entity_registry_enabled_default on an opt-in list read from the config entry; no code has ever written those keys, in any released version, and the options flow writes to entry.options rather than entry.data. A fresh XMPP install therefore showed climate + water_heater and hid everything else. Noisy telemetry is already demoted through entity_category, so the gate bought nothing. Existing installs are untouched — the default only applies to newly registered entities. Solar removal ran on unreliable input. An absent /solarCircuits key deleted the solar device and every entity registry entry on it, but the coordinator silently skips paths that fail, so one timeout during startup destroyed a solar user's entity ids, customisations and history association with no way back. Skipping the descriptions stays unconditional (harmless, reversible); only the deletion now requires a refresh that succeeded and returned data. Off -> Heat reverted within one poll. Off is stored as manual mode at min_temp; setting Heat wrote only userMode, leaving the setpoint at 5 C, so the next coordinator update re-ran the is_off detection and flipped the card back. The pre-Off setpoint is captured on the way in and restored on the way out. Also: the test harness stubbed custom_components.bosch.sensor as an empty shell module, which made its async_setup_entry unimportable and is why the sensor platform sat at 0%. It is imported for real now; no test needed changing. __init__.py takes SWITCH from const rather than re-exporting it through the switch platform. 574 tests pass, ruff clean, coverage 70.6% -> 72%. Every fix has a test that fails without it.
1 parent f1faa17 commit cfd6bf9

13 files changed

Lines changed: 238 additions & 30 deletions

CHANGELOG.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,39 @@ All notable changes to this Bosch Home Assistant custom component will be docume
44

55
## [Unreleased]
66

7+
### Security
8+
- **Diagnostics no longer leak the appliance serial.** POINTTAPI stores the
9+
serial under `address`, `device_id` *and* `uuid` in the config entry, none of
10+
which were redacted, and `/gateway/uuid`'s value passed through untouched
11+
because the redactor matched a top-level `uuid` **key** that real
12+
`{"id": ..., "value": ...}` responses never have. Four plaintext copies in a
13+
file testers routinely paste into public issues.
714
### Added
815
- **Native-first Boost controls** — Redesigned POINTTAPI Boost controls with dedicated per-zone switches (`switch.*_boost`), serialized multi-zone activation via `asyncio.Lock` to prevent race conditions on rapid toggles, and integration into climate preset modes (`boost` / `none`) across all locales (#34).
916
- **Dedicated Heating Circuit (`hc1`) device partition** — Moved circuit-level heating settings away from individual room thermostat devices to a dedicated **Heating Installation** (`/heatingCircuits/hc1`) device to accurately reflect hardware topology.
1017
- **Before:** Global circuit settings (e.g. supply limits, heating slope, boost duration/temperature) were incorrectly attached to the `zn1` room device (Zone 1 / Thermostat), duplicating or misattributing installation-wide properties.
1118
- **After:** Supply limits (`supplyTemperatureLimitMax`, `supplyTemperatureLimitMin`), heating dynamics (`heatupCoolingSlope`, `buildingHeatup`), and global Boost settings (`boostTemperature`, `boostDuration`, `boostRemainingTime`) are properly assigned to the Heating Circuit (`/heatingCircuits/hc1`) device, ensuring clean device separation in Home Assistant (#34).
12-
19+
### Fixed
20+
- **Local (XMPP/HTTP) entities are no longer all disabled on a fresh install.**
21+
Every sensor, binary sensor, switch, select and number gated its
22+
registry-enabled default on a per-entity opt-in list read from the config
23+
entry — which nothing has ever written, in any released version. New local
24+
installs showed a climate and water-heater entity and hid the rest. Existing
25+
installs keep whatever they already have; only newly registered entities are
26+
affected.
27+
- **Solar devices survive a failed refresh.** A missing `/solarCircuits` key
28+
removed the solar device *and every entity registry entry on it* — entity
29+
ids, customisations and history association, irrecoverably. The coordinator
30+
swallows per-path fetch failures, so one timeout during startup was enough.
31+
Removal now requires a refresh that actually succeeded and returned data.
32+
- **Turning a zone back on after Off no longer snaps back to Off.** `Off` is
33+
written as manual mode at the minimum temperature; restoring the mode without
34+
the setpoint left the zone at 5 °C, so the next poll re-detected `Off` and
35+
reverted the card. The pre-`Off` setpoint is now restored with the mode.
1336
### Changed
1437
- **Read-only number paths** — Number entities for POINTTAPI resources that are read-only (`writeable: 0` or `False`) are now hidden/unavailable, ensuring number entities only represent interactive setpoints and controls (#34).
38+
- The sensor platform is imported for real by the test harness instead of being
39+
stubbed, so its setup path can be tested at all. Coverage 70.6% → 72%.
1540

1641
## [1.5.1-beta.1] — 2026-08-31
1742

custom_components/bosch/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ def _get_sensor_class_no_print(device_type, sensor_type):
7979
from homeassistant.util import dt as dt_util
8080
from homeassistant.util.json import load_json
8181

82-
from .switch import SWITCH
8382

8483
from .pointtapi_client import PoinTTAPIClient
8584
from .pointtapi_coordinator import PoinTTAPIDataUpdateCoordinator
@@ -90,6 +89,7 @@ def _get_sensor_class_no_print(device_type, sensor_type):
9089
ACCESS_KEY,
9190
ACCESS_TOKEN,
9291
BINARY_SENSOR,
92+
SWITCH,
9393
CLIMATE,
9494
CONF_DEVICE_TYPE,
9595
CONF_PROTOCOL,

custom_components/bosch/binary_sensor.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from .bosch_entity import BoschEntity
99
from .const import (
10-
BINARY_SENSOR,
1110
CONF_PROTOCOL,
1211
POINTTAPI,
1312
SIGNAL_BINARY_SENSOR_UPDATE_BOSCH,
@@ -58,7 +57,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
5857
uuid = config_entry.data[UUID]
5958
rt_data = config_entry.runtime_data
6059
gateway = rt_data.gateway
61-
enabled_sensors = config_entry.data.get(BINARY_SENSOR, [])
6260
rt_data.binary_sensor = []
6361

6462
for bosch_sensor in gateway.sensors:
@@ -71,7 +69,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
7169
gateway=gateway,
7270
name=bosch_sensor.name,
7371
attr_uri=bosch_sensor.attr_id,
74-
is_enabled=bosch_sensor.attr_id in enabled_sensors,
72+
is_enabled=True,
7573
)
7674
)
7775

custom_components/bosch/diagnostics.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,32 @@
44
from typing import Any
55

66
from homeassistant.config_entries import ConfigEntry
7+
from homeassistant.const import CONF_ADDRESS
78
from homeassistant.core import HomeAssistant
89
from homeassistant.helpers.redact import async_redact_data
910

10-
from .const import CONF_PROTOCOL, POINTTAPI
11+
from .const import CONF_DEVICE_ID, CONF_PROTOCOL, POINTTAPI, UUID
1112

1213
TO_REDACT_CONFIG = {
1314
"access_token",
1415
"refresh_token",
1516
"access_key",
1617
"password",
1718
"expires_at",
19+
# POINTTAPI writes the appliance serial to all three of these
20+
# (config_flow._async_create_pointtapi_entry). It is the pairing
21+
# identifier in /gateways/{device_id}/resource/, and testers paste
22+
# diagnostics into public issues.
23+
CONF_ADDRESS,
24+
CONF_DEVICE_ID,
25+
UUID,
1826
}
1927

28+
# Paths whose *value* identifies the appliance. POINTTAPI responses are
29+
# shaped {"id": ..., "value": ...}, so the key-based checks below never see
30+
# these — the serial sits under "value" and has to be matched on the path.
31+
_IDENTIFYING_PATH_SUFFIXES = ("/uuid", "/serialnumber", "/macaddress")
32+
2033

2134
async def async_get_config_entry_diagnostics(
2235
hass: HomeAssistant, entry: ConfigEntry
@@ -49,6 +62,8 @@ def _redact_path_response(path: str, resp: Any) -> Any:
4962
if not isinstance(resp, dict):
5063
return resp
5164
redacted = dict(resp)
65+
if path.lower().endswith(_IDENTIFYING_PATH_SUFFIXES) and "value" in redacted:
66+
redacted["value"] = "**REDACTED**"
5267
if "uuid" in redacted:
5368
redacted["uuid"] = "**REDACTED**"
5469
if "serialNumber" in redacted:

custom_components/bosch/number.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
from bosch_thermostat_client.const import NUMBER
65
from homeassistant.components.number import NumberEntity
76
from homeassistant.components.number.const import NumberMode
87
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -43,7 +42,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
4342
return True
4443
uuid = config_entry.data[UUID]
4544
gateway = rt_data.gateway
46-
enabled_switches = config_entry.data.get(NUMBER, [])
4745
data_number = []
4846
for switch in gateway.number_switches:
4947
data_number.append(
@@ -55,7 +53,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
5553
name=switch.name,
5654
attr_uri=switch.attr_id,
5755
domain_name="Switches",
58-
is_enabled=switch.attr_id in enabled_switches,
56+
is_enabled=True,
5957
)
6058
)
6159
for circ_type in CIRCUITS:
@@ -72,7 +70,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
7270
attr_uri=switch.attr_id,
7371
domain_name=circuit.name,
7472
circuit_type=circ_type,
75-
is_enabled=switch.attr_id in enabled_switches,
73+
is_enabled=True,
7674
)
7775
)
7876
rt_data.number = data_number

custom_components/bosch/pointtapi_entities.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,9 @@ def __init__(
13601360
self._uuid = uuid
13611361
self._language = _coordinator_language(coordinator)
13621362
self._zone_id = zone_id
1363+
# Setpoint in force before HVACMode.OFF wrote min_temp, so OFF -> HEAT
1364+
# can put it back. None when we did not observe the transition.
1365+
self._pre_off_target: float | None = None
13631366
self._attr_unique_id = f"{entry_id}_pointtapi_{zone_id}"
13641367
self._attr_device_info = _resolve_device_info(
13651368
uuid,
@@ -1500,6 +1503,8 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None:
15001503
"""
15011504
if hvac_mode == HVACMode.OFF:
15021505
try:
1506+
if self._hvac_mode != HVACMode.OFF and self._target is not None:
1507+
self._pre_off_target = float(self._target)
15031508
await self.coordinator.client.put(f"/zones/{self._zone_id}/userMode", "manual")
15041509
await self.coordinator.client.put(f"/zones/{self._zone_id}/manualTemperatureHeating", self.min_temp)
15051510
self._hvac_mode = hvac_mode
@@ -1514,6 +1519,7 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None:
15141519
) from err
15151520
return
15161521

1522+
was_off = self._hvac_mode == HVACMode.OFF
15171523
if hvac_mode == HVACMode.AUTO:
15181524
path = f"/zones/{self._zone_id}/userMode"
15191525
value = "clock"
@@ -1522,6 +1528,21 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None:
15221528
value = "manual"
15231529
try:
15241530
await self.coordinator.client.put(path, value)
1531+
# Leaving OFF needs the setpoint back as well: OFF is stored as
1532+
# manual + min_temp, so restoring only the mode leaves the zone at
1533+
# min_temp and the next poll re-detects OFF and reverts the UI.
1534+
if was_off:
1535+
restored = (
1536+
self._pre_off_target
1537+
if self._pre_off_target is not None
1538+
and self._pre_off_target > self.min_temp
1539+
else self.min_temp + 0.5
1540+
)
1541+
await self.coordinator.client.put(
1542+
f"/zones/{self._zone_id}/manualTemperatureHeating", restored
1543+
)
1544+
self._target = restored
1545+
self._pre_off_target = None
15251546
self._hvac_mode = hvac_mode
15261547
self.async_write_ha_state()
15271548
await self.coordinator.async_request_refresh()

custom_components/bosch/select.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
For more details about this platform, please refer to the documentation at...
55
"""
66
from __future__ import annotations
7-
from bosch_thermostat_client.const import SELECT
87
from homeassistant.components.select import SelectEntity
98
from homeassistant.helpers.dispatcher import async_dispatcher_send
109

@@ -41,7 +40,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
4140
return True
4241
uuid = config_entry.data[UUID]
4342
gateway = rt_data.gateway
44-
enabled = config_entry.data.get(SELECT, [])
4543
rt_data.select = []
4644
selects = gateway.switches.selects
4745
for select in selects:
@@ -54,7 +52,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
5452
name=select.name,
5553
attr_uri=select.attr_id,
5654
domain_name="Select",
57-
is_enabled=select.attr_id in enabled,
55+
is_enabled=True,
5856
)
5957
)
6058
async_add_entities(rt_data.select)

custom_components/bosch/sensor/__init__.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
RECORDING,
66
REGULAR,
77
SENSOR,
8-
SENSORS,
98
)
109
from bosch_thermostat_client.const.easycontrol import ENERGY
1110
from homeassistant.helpers import device_registry as dr
@@ -68,7 +67,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
6867
# first coordinator refresh returned no usable /solarCircuits/sc1 data.
6968
# This stops non-solar households from seeing four ghost entities.
7069
solar_available = _solar_data_available(coordinator.data or {})
71-
if not solar_available:
70+
# Skipping the descriptions is harmless and reversible. *Removing*
71+
# an existing solar device is neither — it destroys entity_ids,
72+
# customisations and history association. coordinator.data can be
73+
# missing /solarCircuits after a timeout or a partial bulk envelope
74+
# (the coordinator swallows per-path failures), so only remove once
75+
# a refresh has actually succeeded and returned data.
76+
if not solar_available and coordinator.last_update_success and coordinator.data:
7277
_remove_solar_registry_entries(hass, uuid)
7378
descriptions = [
7479
desc
@@ -115,7 +120,6 @@ async def _do_backfill(_now=None):
115120
return True
116121
uuid = config_entry.data[UUID]
117122
gateway = rt_data.gateway
118-
enabled_sensors = config_entry.data.get(SENSORS, [])
119123

120124
new_stats_api = config_entry.options.get("new_stats_api", False)
121125
rt_data.sensor = []
@@ -140,7 +144,7 @@ def get_sensors(sensor):
140144
gateway=gateway,
141145
name=sensor.name,
142146
attr_uri=sensor.attr_id,
143-
is_enabled=sensor.attr_id in enabled_sensors,
147+
is_enabled=True,
144148
**kwargs
145149
)
146150
],
@@ -157,7 +161,7 @@ def get_sensors(sensor):
157161
sensor_attributes=energy,
158162
attr_uri=sensor.attr_id,
159163
new_stats_api=new_stats_api,
160-
is_enabled=sensor.attr_id in enabled_sensors,
164+
is_enabled=True,
161165
)
162166
for energy in EnergySensors
163167
],
@@ -174,7 +178,7 @@ def get_sensors(sensor):
174178
sensor_attributes=energy,
175179
attr_uri=sensor.attr_id,
176180
new_stats_api=new_stats_api,
177-
is_enabled=sensor.attr_id in enabled_sensors,
181+
is_enabled=True,
178182
)
179183
for energy in EcusRecordingSensors
180184
],
@@ -203,7 +207,7 @@ def get_sensors(sensor):
203207
attr_uri=sensor.attr_id,
204208
domain_name=circuit.name,
205209
circuit_type=circ_type,
206-
is_enabled=sensor.attr_id in enabled_sensors,
210+
is_enabled=True,
207211
)
208212
)
209213
async_add_entities(rt_data.sensor)

custom_components/bosch/switch.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
POINTTAPI,
1717
SIGNAL_BOSCH,
1818
SIGNAL_SWITCH,
19-
SWITCH,
2019
UUID,
2120
)
2221
from .pointtapi_entities import (
@@ -67,7 +66,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
6766
return True
6867
uuid = config_entry.data[UUID]
6968
gateway = rt_data.gateway
70-
enabled_switches = config_entry.data.get(SWITCH, [])
7169
data_switch = []
7270
for switch in gateway.regular_switches:
7371
data_switch.append(
@@ -79,7 +77,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
7977
name=switch.name,
8078
attr_uri=switch.attr_id,
8179
domain_name="Switches",
82-
is_enabled=switch.attr_id in enabled_switches,
80+
is_enabled=True,
8381
)
8482
)
8583
for circ_type in CIRCUITS:
@@ -96,7 +94,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
9694
attr_uri=switch.attr_id,
9795
domain_name=circuit.name,
9896
circuit_type=circ_type,
99-
is_enabled=switch.attr_id in enabled_switches,
97+
is_enabled=True,
10098
)
10199
)
102100
rt_data.switch = data_switch

unittests/conftest.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,10 @@
3636
# executing the real __init__.py.
3737
_bosch_pkg.create_notification_firmware = MagicMock()
3838

39-
# Also make the sensor sub-package discoverable
40-
_sensor_pkg = ModuleType("custom_components.bosch.sensor")
41-
_sensor_pkg.__path__ = [str(REPO_ROOT / "custom_components" / "bosch" / "sensor")]
42-
_sensor_pkg.__package__ = "custom_components.bosch.sensor"
43-
sys.modules.setdefault("custom_components.bosch.sensor", _sensor_pkg)
39+
# The sensor sub-package is imported for real (not shelled like the parent):
40+
# its __init__.py has no heavy side effects, and shelling it made
41+
# async_setup_entry unreachable from tests — which is why the sensor platform
42+
# setup had no coverage at all.
4443

4544

4645
# ── Fixtures ─────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)