Diff to HTML by rtfpessoa

Files changed (15) hide show
  1. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/__init__.py +44 -91
  2. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/binary_sensor.py +100 -70
  3. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/climate.py +237 -117
  4. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/config_flow.py +143 -185
  5. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/const.py +101 -36
  6. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/coordinator.py +167 -69
  7. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/diagnostics.py +4 -15
  8. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/entity.py +49 -38
  9. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/manifest.json +7 -5
  10. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/number.py +127 -78
  11. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/select.py +122 -70
  12. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/sensor.py +413 -33
  13. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/strings.json +255 -153
  14. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/switch.py +106 -28
  15. /home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/util.py +179 -11
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/__init__.py RENAMED
@@ -1,121 +1,68 @@
1
  """Plugwise platform for Home Assistant Core."""
2
- from __future__ import annotations
3
 
4
  from typing import Any
5
 
6
- import voluptuous as vol
7
-
8
- from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
9
- from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
10
- from homeassistant.config_entries import ConfigEntry
11
  from homeassistant.const import Platform
12
- from homeassistant.core import HomeAssistant, ServiceCall, callback
13
  from homeassistant.helpers import device_registry as dr, entity_registry as er
14
- from plugwise.exceptions import PlugwiseError
15
 
16
- from .const import (
17
- CONF_REFRESH_INTERVAL, # pw-beta options
18
- COORDINATOR,
19
- DOMAIN,
20
- LOGGER,
21
- PLATFORMS_GATEWAY,
22
- SERVICE_DELETE,
23
- UNDO_UPDATE_LISTENER,
24
- )
25
- from .coordinator import PlugwiseDataUpdateCoordinator
26
 
27
 
28
- async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
29
- """Set up Plugwise Smiles from a config entry."""
30
  await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry)
31
 
32
- cooldown = 1.5 # pw-beta frontend refresh-interval
33
- if (
34
- custom_refresh := entry.options.get(CONF_REFRESH_INTERVAL)
35
- ) is not None: # pragma: no cover
36
- cooldown = custom_refresh
37
- LOGGER.debug("DUC cooldown interval: %s", cooldown)
38
-
39
- coordinator = PlugwiseDataUpdateCoordinator(
40
- hass, entry, cooldown
41
- ) # pw-beta - cooldown, update_interval as extra
42
  await coordinator.async_config_entry_first_refresh()
43
- # Migrate a changed sensor unique_id
44
  migrate_sensor_entities(hass, coordinator)
45
 
46
- undo_listener = entry.add_update_listener(_update_listener) # pw-beta
47
-
48
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
49
- COORDINATOR: coordinator, # pw-beta
50
- UNDO_UPDATE_LISTENER: undo_listener, # pw-beta
51
- }
52
 
53
  device_registry = dr.async_get(hass)
54
  device_registry.async_get_or_create(
55
  config_entry_id=entry.entry_id,
56
  identifiers={(DOMAIN, str(coordinator.api.gateway_id))},
57
  manufacturer="Plugwise",
58
- model=coordinator.api.smile_model,
59
- name=coordinator.api.smile_name,
60
- sw_version=coordinator.api.smile_version[0],
61
- )
62
-
63
- async def delete_notification(
64
- call: ServiceCall,
65
- ) -> None: # pragma: no cover # pw-beta: HA service - delete_notification
66
- """Service: delete the Plugwise Notification."""
67
- LOGGER.debug(
68
- "Service delete PW Notification called for %s",
69
- coordinator.api.smile_name,
70
- )
71
- try:
72
- await coordinator.api.delete_notification()
73
- LOGGER.debug("PW Notification deleted")
74
- except PlugwiseError:
75
- LOGGER.debug(
76
- "Failed to delete the Plugwise Notification for %s",
77
- coordinator.api.smile_name,
78
- )
79
 
80
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS_GATEWAY)
81
 
82
- for component in PLATFORMS_GATEWAY: # pw-beta
83
- if component == Platform.BINARY_SENSOR:
84
- hass.services.async_register(
85
- DOMAIN, SERVICE_DELETE, delete_notification, schema=vol.Schema({})
86
- )
87
-
88
- return True
89
-
90
-
91
- async def _update_listener(
92
- hass: HomeAssistant, entry: ConfigEntry
93
- ) -> None: # pragma: no cover # pw-beta
94
- """Handle options update."""
95
- await hass.config_entries.async_reload(entry.entry_id)
96
-
97
-
98
- async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
99
- """Unload a config entry."""
100
- if unload_ok := await hass.config_entries.async_unload_platforms(
101
- entry, PLATFORMS_GATEWAY
102
- ):
103
- hass.data[DOMAIN].pop(entry.entry_id)
104
- return unload_ok
105
 
106
 
107
  @callback
108
  def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None:
109
  """Migrate Plugwise entity entries.
110
 
111
- - Migrates unique ID from old relay switches to the new unique ID
 
112
  """
113
- if entry.domain == SWITCH_DOMAIN and entry.unique_id.endswith("-"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  return {"new_unique_id": entry.unique_id.replace("-plug", "-relay")}
115
 
116
- if entry.domain == NUMBER_DOMAIN and entry.unique_id.endswith("-domestic_hot_water_setpoint"):
117
- return {"new_unique_id": entry.unique_id.replace("-domestic_hot_water_setpoint", "-max_dhw_temperature")}
118
-
119
  # No migration needed
120
  return None
121
 
@@ -127,10 +74,10 @@
127
  """Migrate Sensors if needed."""
128
  ent_reg = er.async_get(hass)
129
 
130
- # Migrate opentherm_outdoor_temperature to # pw-beta add to Core
131
- # opentherm_outdoor_air_temperature sensor
132
- for device_id, device in coordinator.data.devices.items():
133
- if device["dev_class"] != "heater_central": # pw-beta add to Core
134
  continue
135
 
136
  old_unique_id = f"{device_id}-outdoor_temperature"
@@ -138,4 +85,10 @@
138
  Platform.SENSOR, DOMAIN, old_unique_id
139
  ):
140
  new_unique_id = f"{device_id}-outdoor_air_temperature"
 
 
 
 
 
 
141
  ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
 
1
  """Plugwise platform for Home Assistant Core."""
 
2
 
3
  from typing import Any
4
 
 
 
 
 
 
5
  from homeassistant.const import Platform
6
+ from homeassistant.core import HomeAssistant, callback
7
  from homeassistant.helpers import device_registry as dr, entity_registry as er
 
8
 
9
+ from .const import DEV_CLASS, DOMAIN, LOGGER, PLATFORMS
10
+ from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
 
 
 
 
 
 
 
 
11
 
12
 
13
+ async def async_setup_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> None:
14
+ """Set up Plugwise components from a config entry."""
15
  await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry)
16
 
17
+ coordinator = PlugwiseDataUpdateCoordinator(hass, entry)
 
 
 
 
 
 
 
 
 
18
  await coordinator.async_config_entry_first_refresh()
 
19
  migrate_sensor_entities(hass, coordinator)
20
 
21
+ entry.runtime_data = coordinator
 
 
 
 
 
22
 
23
  device_registry = dr.async_get(hass)
24
  device_registry.async_get_or_create(
25
  config_entry_id=entry.entry_id,
26
  identifiers={(DOMAIN, str(coordinator.api.gateway_id))},
27
  manufacturer="Plugwise",
28
+ model=coordinator.api.smile.model,
29
+ model_id=coordinator.api.smile.model_id,
30
+ name=coordinator.api.smile.name,
31
+ sw_version=str(coordinator.api.smile.version),
32
+ ) # required for adding the entity-less P1 Gateway
33
+
34
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
 
36
 
37
+ async def async_unload_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> None:
38
+ """Unload the Plugwise components."""
39
+ await hass.config_entries.async_unload_platforms_or_raise(entry, PLATFORMS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
 
42
  @callback
43
  def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None:
44
  """Migrate Plugwise entity entries.
45
 
46
+ Migrates old unique ID's from old binary_sensors and
47
+ switches to the new unique ID's.
48
  """
49
+ if entry.domain == Platform.BINARY_SENSOR and entry.unique_id.endswith(
50
+ "-slave_boiler_state"
51
+ ):
52
+ return {
53
+ "new_unique_id": entry.unique_id.replace(
54
+ "-slave_boiler_state", "-secondary_boiler_state"
55
+ )
56
+ }
57
+ if entry.domain == Platform.SENSOR and entry.unique_id.endswith(
58
+ "-relative_humidity"
59
+ ):
60
+ return {
61
+ "new_unique_id": entry.unique_id.replace("-relative_humidity", "-humidity")
62
+ }
63
+ if entry.domain == Platform.SWITCH and entry.unique_id.endswith("-plug"):
64
  return {"new_unique_id": entry.unique_id.replace("-plug", "-relay")}
65
 
 
 
 
66
  # No migration needed
67
  return None
68
 
 
74
  """Migrate Sensors if needed."""
75
  ent_reg = er.async_get(hass)
76
 
77
+ # Migrating opentherm_outdoor_temperature
78
+ # to opentherm_outdoor_air_temperature sensor
79
+ for device_id, device in coordinator.data.items():
80
+ if device[DEV_CLASS] != "heater_central":
81
  continue
82
 
83
  old_unique_id = f"{device_id}-outdoor_temperature"
 
85
  Platform.SENSOR, DOMAIN, old_unique_id
86
  ):
87
  new_unique_id = f"{device_id}-outdoor_air_temperature"
88
+ LOGGER.debug(
89
+ "Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
90
+ entity_id,
91
+ old_unique_id,
92
+ new_unique_id,
93
+ )
94
  ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/binary_sensor.py RENAMED
@@ -1,54 +1,109 @@
1
  """Plugwise Binary Sensor component for Home Assistant."""
2
- from __future__ import annotations
3
 
4
  from collections.abc import Mapping
5
- from typing import Any
 
6
 
7
- from homeassistant.components.binary_sensor import BinarySensorEntity
8
- from homeassistant.config_entries import ConfigEntry
9
- from homeassistant.core import HomeAssistant
10
- from homeassistant.helpers.entity_platform import AddEntitiesCallback
11
-
12
- from .const import (
13
- COORDINATOR, # pw-beta
14
- DOMAIN,
15
- LOGGER,
16
- SEVERITIES,
17
  )
18
- from .coordinator import PlugwiseDataUpdateCoordinator
 
 
 
 
19
  from .entity import PlugwiseEntity
20
- from .models import PW_BINARY_SENSOR_TYPES, PlugwiseBinarySensorEntityDescription
21
 
 
 
 
22
  PARALLEL_UPDATES = 0
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  async def async_setup_entry(
26
  hass: HomeAssistant,
27
- config_entry: ConfigEntry,
28
- async_add_entities: AddEntitiesCallback,
29
  ) -> None:
30
- """Set up Plugwise binary sensor based on config_entry."""
31
- coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][
32
- config_entry.entry_id
33
- ][COORDINATOR]
34
-
35
- entities: list[PlugwiseBinarySensorEntity] = []
36
- for device_id, device in coordinator.data.devices.items():
37
- if not (binary_sensors := device.get("binary_sensors")):
38
- continue
39
- for description in PW_BINARY_SENSOR_TYPES:
40
- if description.key not in binary_sensors:
41
- continue
42
- entities.append(
43
- PlugwiseBinarySensorEntity(
44
- coordinator,
45
- device_id,
46
- description,
47
- )
48
- )
49
- LOGGER.debug("Add %s binary sensor", description.key)
50
 
51
- async_add_entities(entities)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity):
@@ -66,53 +121,28 @@
66
  super().__init__(coordinator, device_id)
67
  self.entity_description = description
68
  self._attr_unique_id = f"{device_id}-{description.key}"
69
- self._notification: dict[str, str] = {} # pw-beta
70
 
71
  @property
 
72
  def is_on(self) -> bool:
73
  """Return true if the binary sensor is on."""
74
- # pw-beta: show Plugwise notifications as HA persistent notifications
75
- if self._notification:
76
- for notify_id, message in self._notification.items():
77
- self.hass.components.persistent_notification.async_create(
78
- message, "Plugwise Notification:", f"{DOMAIN}.{notify_id}"
79
- )
80
-
81
- # return self.device["binary_sensors"][self.entity_description.key] # type: ignore [literal-required]
82
- return self.entity_description.value_fn(self.device["binary_sensors"])
83
-
84
- @property
85
- def icon(self) -> str | None:
86
- """Return the icon to use in the frontend, if any."""
87
- if (icon_off := self.entity_description.icon_off) and self.is_on is False:
88
- return icon_off
89
- return self.entity_description.icon
90
 
91
  @property
 
92
  def extra_state_attributes(self) -> Mapping[str, Any] | None:
93
  """Return entity specific state attributes."""
94
  if self.entity_description.key != "plugwise_notification":
95
  return None
96
 
97
- # pw-beta adjustment with attrs is to only represent severities *with* content
98
- # not all severities including those without content as empty lists
99
- attrs: dict[str, list[str]] = {} # pw-beta Re-evaluate against Core
100
- self._notification = {} # pw-beta
101
- if notify := self.coordinator.data.gateway["notifications"]:
102
- for notify_id, details in notify.items(): # pw-beta uses notify_id
103
  for msg_type, msg in details.items():
104
  msg_type = msg_type.lower()
105
  if msg_type not in SEVERITIES:
106
- msg_type = "other" # pragma: no cover
107
-
108
- if (
109
- f"{msg_type}_msg" not in attrs
110
- ): # pw-beta Re-evaluate against Core
111
- attrs[f"{msg_type}_msg"] = []
112
  attrs[f"{msg_type}_msg"].append(msg)
113
 
114
- self._notification[
115
- notify_id
116
- ] = f"{msg_type.title()}: {msg}" # pw-beta
117
-
118
  return attrs
 
1
  """Plugwise Binary Sensor component for Home Assistant."""
 
2
 
3
  from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any, override
6
 
7
+ from plugwise.constants import BinarySensorType
8
+
9
+ from homeassistant.components.binary_sensor import (
10
+ BinarySensorDeviceClass,
11
+ BinarySensorEntity,
12
+ BinarySensorEntityDescription,
 
 
 
 
13
  )
14
+ from homeassistant.const import EntityCategory
15
+ from homeassistant.core import HomeAssistant, callback
16
+ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
17
+
18
+ from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
19
  from .entity import PlugwiseEntity
 
20
 
21
+ SEVERITIES = ["other", "info", "warning", "error"]
22
+
23
+ # Coordinator is used to centralize the data updates
24
  PARALLEL_UPDATES = 0
25
 
26
 
27
+ @dataclass(frozen=True)
28
+ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription):
29
+ """Describes a Plugwise binary sensor entity."""
30
+
31
+ key: BinarySensorType
32
+
33
+
34
+ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
35
+ PlugwiseBinarySensorEntityDescription(
36
+ key="low_battery",
37
+ device_class=BinarySensorDeviceClass.BATTERY,
38
+ entity_category=EntityCategory.DIAGNOSTIC,
39
+ ),
40
+ PlugwiseBinarySensorEntityDescription(
41
+ key="compressor_state",
42
+ translation_key="compressor_state",
43
+ entity_category=EntityCategory.DIAGNOSTIC,
44
+ ),
45
+ PlugwiseBinarySensorEntityDescription(
46
+ key="cooling_enabled",
47
+ translation_key="cooling_enabled",
48
+ entity_category=EntityCategory.DIAGNOSTIC,
49
+ ),
50
+ PlugwiseBinarySensorEntityDescription(
51
+ key="dhw_state",
52
+ translation_key="dhw_state",
53
+ entity_category=EntityCategory.DIAGNOSTIC,
54
+ ),
55
+ PlugwiseBinarySensorEntityDescription(
56
+ key="flame_state",
57
+ translation_key="flame_state",
58
+ entity_category=EntityCategory.DIAGNOSTIC,
59
+ ),
60
+ PlugwiseBinarySensorEntityDescription(
61
+ key="heating_state",
62
+ translation_key="heating_state",
63
+ entity_category=EntityCategory.DIAGNOSTIC,
64
+ ),
65
+ PlugwiseBinarySensorEntityDescription(
66
+ key="cooling_state",
67
+ translation_key="cooling_state",
68
+ entity_category=EntityCategory.DIAGNOSTIC,
69
+ ),
70
+ PlugwiseBinarySensorEntityDescription(
71
+ key="secondary_boiler_state",
72
+ translation_key="secondary_boiler_state",
73
+ entity_category=EntityCategory.DIAGNOSTIC,
74
+ ),
75
+ PlugwiseBinarySensorEntityDescription(
76
+ key="plugwise_notification",
77
+ translation_key="plugwise_notification",
78
+ entity_category=EntityCategory.DIAGNOSTIC,
79
+ ),
80
+ )
81
+
82
+
83
  async def async_setup_entry(
84
  hass: HomeAssistant,
85
+ entry: PlugwiseConfigEntry,
86
+ async_add_entities: AddConfigEntryEntitiesCallback,
87
  ) -> None:
88
+ """Set up the Smile binary_sensors from a config entry."""
89
+ coordinator = entry.runtime_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
+ @callback
92
+ def _add_entities() -> None:
93
+ """Add Entities."""
94
+ if not coordinator.new_devices:
95
+ return
96
+
97
+ async_add_entities(
98
+ PlugwiseBinarySensorEntity(coordinator, device_id, description)
99
+ for device_id in coordinator.new_devices
100
+ if (binary_sensors := coordinator.data[device_id].get("binary_sensors"))
101
+ for description in BINARY_SENSORS
102
+ if description.key in binary_sensors
103
+ )
104
+
105
+ _add_entities()
106
+ entry.async_on_unload(coordinator.async_add_listener(_add_entities))
107
 
108
 
109
  class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity):
 
121
  super().__init__(coordinator, device_id)
122
  self.entity_description = description
123
  self._attr_unique_id = f"{device_id}-{description.key}"
 
124
 
125
  @property
126
+ @override
127
  def is_on(self) -> bool:
128
  """Return true if the binary sensor is on."""
129
+ return self.device["binary_sensors"][self.entity_description.key]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  @property
132
+ @override
133
  def extra_state_attributes(self) -> Mapping[str, Any] | None:
134
  """Return entity specific state attributes."""
135
  if self.entity_description.key != "plugwise_notification":
136
  return None
137
 
138
+ attrs: dict[str, list[str]] = {f"{severity}_msg": [] for severity in SEVERITIES}
139
+ gateway_id = self.coordinator.api.gateway_id
140
+ if notify := self.coordinator.data[gateway_id]["notifications"]:
141
+ for details in notify.values():
 
 
142
  for msg_type, msg in details.items():
143
  msg_type = msg_type.lower()
144
  if msg_type not in SEVERITIES:
145
+ msg_type = "other"
 
 
 
 
 
146
  attrs[f"{msg_type}_msg"].append(msg)
147
 
 
 
 
 
148
  return attrs
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/climate.py RENAMED
@@ -1,7 +1,7 @@
1
  """Plugwise Climate component for Home Assistant."""
2
- from __future__ import annotations
3
 
4
- from typing import Any
 
5
 
6
  from homeassistant.components.climate import (
7
  ATTR_HVAC_MODE,
@@ -12,102 +12,164 @@
12
  HVACAction,
13
  HVACMode,
14
  )
15
- from homeassistant.components.climate.const import (
16
- PRESET_AWAY, # pw-beta homekit emulation
17
- PRESET_HOME, # pw-beta homekit emulation
18
- )
19
- from homeassistant.config_entries import ConfigEntry
20
- from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
21
- from homeassistant.core import HomeAssistant
22
  from homeassistant.exceptions import HomeAssistantError
23
- from homeassistant.helpers.entity_platform import AddEntitiesCallback
 
24
 
25
- from .const import (
26
- CONF_HOMEKIT_EMULATION, # pw-beta homekit emulation
27
- COORDINATOR, # pw-beta
28
- DOMAIN,
29
- MASTER_THERMOSTATS,
30
- )
31
- from .coordinator import PlugwiseDataUpdateCoordinator
32
  from .entity import PlugwiseEntity
33
  from .util import plugwise_command
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  async def async_setup_entry(
37
  hass: HomeAssistant,
38
- config_entry: ConfigEntry,
39
- async_add_entities: AddEntitiesCallback,
40
  ) -> None:
41
  """Set up the Smile Thermostats from a config entry."""
42
- coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][
43
- config_entry.entry_id
44
- ][COORDINATOR]
45
-
46
- homekit_enabled: bool = config_entry.options.get(
47
- CONF_HOMEKIT_EMULATION, False
48
- ) # pw-beta homekit emulation
49
-
50
- async_add_entities(
51
- PlugwiseClimateEntity(
52
- coordinator, device_id, homekit_enabled
53
- ) # pw-beta homekit emulation
54
- for device_id, device in coordinator.data.devices.items()
55
- if device["dev_class"] in MASTER_THERMOSTATS
56
- )
 
 
 
 
 
 
 
 
57
 
58
 
59
- class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
60
- """Representation of an Plugwise thermostat."""
61
 
62
- _attr_has_entity_name = True
63
- _attr_temperature_unit = UnitOfTemperature.CELSIUS
64
  _attr_translation_key = DOMAIN
65
 
66
  def __init__(
67
  self,
68
  coordinator: PlugwiseDataUpdateCoordinator,
69
  device_id: str,
70
- homekit_enabled: bool, # pw-beta homekit emulation
71
  ) -> None:
72
  """Set up the Plugwise API."""
73
  super().__init__(coordinator, device_id)
74
- self._homekit_enabled = homekit_enabled # pw-beta homekit emulation
75
- self._homekit_mode: str | None = None # pw-beta homekit emulation
76
- self._attr_unique_id = f"{device_id}-climate"
 
 
 
 
 
 
 
 
77
 
78
  # Determine supported features
79
  self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
80
- if self.coordinator.data.gateway["cooling_present"]:
81
  self._attr_supported_features = (
82
  ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
83
  )
 
 
 
 
84
  if presets := self.device.get("preset_modes"):
85
  self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
86
  self._attr_preset_modes = presets
87
 
88
- # Determine hvac modes and current hvac mode
89
- self._attr_hvac_modes = [HVACMode.HEAT]
90
- if self.coordinator.data.gateway["cooling_present"]:
91
- self._attr_hvac_modes = [HVACMode.HEAT_COOL]
92
- if self.device["available_schedules"] != ["None"]:
93
- self._attr_hvac_modes.append(HVACMode.AUTO)
94
- if self._homekit_enabled: # pw-beta homekit emulation
95
- self._attr_hvac_modes.append(HVACMode.OFF) # pragma: no cover
96
-
97
  self._attr_min_temp = self.device["thermostat"]["lower_bound"]
98
- self._attr_max_temp = self.device["thermostat"]["upper_bound"]
99
- # Fix unpractical resolution provided by Plugwise
100
  self._attr_target_temperature_step = max(
101
- self.device["thermostat"]["resolution"], 0.5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  )
103
 
104
  @property
105
- def current_temperature(self) -> float:
 
106
  """Return the current temperature."""
107
- return self.device["sensors"]["temperature"]
108
 
109
  @property
110
- def target_temperature(self) -> float:
 
111
  """Return the temperature we try to reach.
112
 
113
  Connected to the HVACMode combination of AUTO-HEAT.
@@ -116,7 +178,8 @@
116
  return self.device["thermostat"]["setpoint"]
117
 
118
  @property
119
- def target_temperature_high(self) -> float:
 
120
  """Return the temperature we try to reach in case of cooling.
121
 
122
  Connected to the HVACMode combination of AUTO-HEAT_COOL.
@@ -124,7 +187,8 @@
124
  return self.device["thermostat"]["setpoint_high"]
125
 
126
  @property
127
- def target_temperature_low(self) -> float:
 
128
  """Return the heating temperature we try to reach in case of heating.
129
 
130
  Connected to the HVACMode combination AUTO-HEAT_COOL.
@@ -132,54 +196,68 @@
132
  return self.device["thermostat"]["setpoint_low"]
133
 
134
  @property
 
135
  def hvac_mode(self) -> HVACMode:
136
- """Return HVAC operation ie. auto, heat, heat_cool, or off mode."""
137
  if (
138
- mode := self.device["mode"]
139
- ) is None or mode not in self.hvac_modes: # pw-beta add to Core
140
- return HVACMode.HEAT # pragma: no cover
141
- # pw-beta homekit emulation
142
- if self._homekit_enabled and self._homekit_mode == HVACMode.OFF:
143
- mode = HVACMode.OFF # pragma: no cover
144
-
145
  return HVACMode(mode)
146
 
147
  @property
148
- def hvac_action(self) -> HVACAction: # pw-beta add to Core
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  """Return the current running hvac operation if supported."""
150
- # When control_state is present, prefer this data
151
- if (control_state := self.device.get("control_state")) == "cooling":
152
- return HVACAction.COOLING
153
- # Support preheating state as heating,
154
- # until preheating is added as a separate state
155
- if control_state in ["heating", "preheating"]:
156
- return HVACAction.HEATING
157
- if control_state == "off":
158
- return HVACAction.IDLE
159
-
160
- heater: str | None = self.coordinator.data.gateway["heater_id"]
161
- if heater is not None:
162
- heater_data = self.coordinator.data.devices[heater]
163
- if heater_data["binary_sensors"]["heating_state"]:
164
- return HVACAction.HEATING
165
- if heater_data["binary_sensors"].get("cooling_state", False):
166
- return HVACAction.COOLING
167
 
168
  return HVACAction.IDLE
169
 
170
  @property
 
171
  def preset_mode(self) -> str | None:
172
  """Return the current preset mode."""
173
- return self.device["active_preset"]
174
 
175
  @plugwise_command
 
176
  async def async_set_temperature(self, **kwargs: Any) -> None:
177
  """Set new target temperature."""
178
- if ATTR_HVAC_MODE in kwargs: # pw-beta add to Core
179
- await self.async_set_hvac_mode(
180
- kwargs[ATTR_HVAC_MODE]
181
- ) # pw-beta add to Core
182
-
183
  data: dict[str, Any] = {}
184
  if ATTR_TEMPERATURE in kwargs:
185
  data["setpoint"] = kwargs.get(ATTR_TEMPERATURE)
@@ -188,38 +266,80 @@
188
  if ATTR_TARGET_TEMP_LOW in kwargs:
189
  data["setpoint_low"] = kwargs.get(ATTR_TARGET_TEMP_LOW)
190
 
191
- for temperature in data.values():
192
- if temperature is None or not (
193
- self._attr_min_temp <= temperature <= self._attr_max_temp
194
- ):
195
- raise ValueError("Invalid temperature change requested")
196
 
197
- await self.coordinator.api.set_temperature(self.device["location"], data)
 
 
 
 
 
 
 
 
 
 
 
198
 
199
  @plugwise_command
 
200
  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
201
- """Set the hvac mode."""
202
- if hvac_mode not in self.hvac_modes:
203
- raise HomeAssistantError("Unsupported hvac_mode")
204
-
205
- await self.coordinator.api.set_schedule_state(
206
- self.device["location"],
207
- self.device["last_used"],
208
- "on" if hvac_mode == HVACMode.AUTO else "off",
 
 
 
 
 
 
209
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
- # pw-beta: feature request - mimic HomeKit behavior
212
- self._homekit_mode = hvac_mode
213
- if self._homekit_enabled:
214
- if self._homekit_mode == HVACMode.OFF: # pragma: no cover
215
- await self.async_set_preset_mode(PRESET_AWAY) # pragma: no cover
216
- if (
217
- self._homekit_mode in [HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL]
218
- and self.device["active_preset"] == PRESET_AWAY
219
- ): # pragma: no cover
220
- await self.async_set_preset_mode(PRESET_HOME) # pragma: no cover
221
 
222
  @plugwise_command
 
223
  async def async_set_preset_mode(self, preset_mode: str) -> None:
224
  """Set the preset mode."""
225
- await self.coordinator.api.set_preset(self.device["location"], preset_mode)
 
1
  """Plugwise Climate component for Home Assistant."""
 
2
 
3
+ from dataclasses import asdict, dataclass
4
+ from typing import Any, override
5
 
6
  from homeassistant.components.climate import (
7
  ATTR_HVAC_MODE,
 
12
  HVACAction,
13
  HVACMode,
14
  )
15
+ from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, STATE_ON, UnitOfTemperature
16
+ from homeassistant.core import HomeAssistant, callback
 
 
 
 
 
17
  from homeassistant.exceptions import HomeAssistantError
18
+ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
19
+ from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
20
 
21
+ from .const import DOMAIN, MASTER_THERMOSTATS
22
+ from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
 
 
 
 
 
23
  from .entity import PlugwiseEntity
24
  from .util import plugwise_command
25
 
26
+ ERROR_NO_SCHEDULE = "set_schedule_first"
27
+ PARALLEL_UPDATES = 0
28
+
29
+
30
+ def _check_for_schedule(active: bool, last_active: str) -> None:
31
+ """Raise a HAError when no thermostat schedule has been set."""
32
+ # last_active can be stored as None from before the plugwise v1.14.3 bump
33
+ if not active and last_active == STATE_OFF:
34
+ raise HomeAssistantError(
35
+ translation_domain=DOMAIN,
36
+ translation_key=ERROR_NO_SCHEDULE,
37
+ )
38
+
39
+
40
+ @dataclass
41
+ class PlugwiseClimateExtraStoredData(ExtraStoredData):
42
+ """Object to hold extra stored data."""
43
+
44
+ last_active_schedule: str | None
45
+ previous_action_mode: str | None
46
+
47
+ @override
48
+ def as_dict(self) -> dict[str, Any]:
49
+ """Return a dict representation of the text data."""
50
+ return asdict(self)
51
+
52
+ @classmethod
53
+ def from_dict(cls, restored: dict[str, Any]) -> PlugwiseClimateExtraStoredData:
54
+ """Initialize a stored data object from a dict."""
55
+ return cls(
56
+ last_active_schedule=restored.get("last_active_schedule"),
57
+ previous_action_mode=restored.get("previous_action_mode"),
58
+ )
59
+
60
 
61
  async def async_setup_entry(
62
  hass: HomeAssistant,
63
+ entry: PlugwiseConfigEntry,
64
+ async_add_entities: AddConfigEntryEntitiesCallback,
65
  ) -> None:
66
  """Set up the Smile Thermostats from a config entry."""
67
+ coordinator = entry.runtime_data
68
+
69
+ @callback
70
+ def _add_entities() -> None:
71
+ """Add Entities."""
72
+ if not coordinator.new_devices:
73
+ return
74
+
75
+ if coordinator.api.smile.name == "Adam":
76
+ async_add_entities(
77
+ PlugwiseClimateEntity(coordinator, device_id)
78
+ for device_id in coordinator.new_devices
79
+ if coordinator.data[device_id]["dev_class"] == "climate"
80
+ )
81
+ else:
82
+ async_add_entities(
83
+ PlugwiseClimateEntity(coordinator, device_id)
84
+ for device_id in coordinator.new_devices
85
+ if coordinator.data[device_id]["dev_class"] in MASTER_THERMOSTATS
86
+ )
87
+
88
+ _add_entities()
89
+ entry.async_on_unload(coordinator.async_add_listener(_add_entities))
90
 
91
 
92
+ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
93
+ """Representation of a Plugwise thermostat."""
94
 
95
+ _attr_name = None
96
+ _attr_native_temperature_unit = UnitOfTemperature.CELSIUS
97
  _attr_translation_key = DOMAIN
98
 
99
  def __init__(
100
  self,
101
  coordinator: PlugwiseDataUpdateCoordinator,
102
  device_id: str,
 
103
  ) -> None:
104
  """Set up the Plugwise API."""
105
  super().__init__(coordinator, device_id)
106
+ # Legacy format, kept as migrating existing unique IDs is not worth the risk
107
+ self._attr_unique_id = f"{device_id}-climate" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
108
+
109
+ self._api = coordinator.api
110
+ gateway_id: str = self._api.gateway_id
111
+ self._gateway_data = coordinator.data[gateway_id]
112
+ self._last_active_schedule = STATE_OFF
113
+ self._location = device_id
114
+ if (location := self.device.get("location")) is not None:
115
+ self._location = location
116
+ self._previous_action_mode = HVACAction.HEATING.value
117
 
118
  # Determine supported features
119
  self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
120
+ if self._api.cooling_present and self._api.smile.name != "Adam":
121
  self._attr_supported_features = (
122
  ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
123
  )
124
+ if HVACMode.OFF in self.hvac_modes:
125
+ self._attr_supported_features |= (
126
+ ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
127
+ )
128
  if presets := self.device.get("preset_modes"):
129
  self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
130
  self._attr_preset_modes = presets
131
 
 
 
 
 
 
 
 
 
 
132
  self._attr_min_temp = self.device["thermostat"]["lower_bound"]
133
+ self._attr_max_temp = min(self.device["thermostat"]["upper_bound"], 35.0)
134
+ # Ensure we don't drop below 0.1
135
  self._attr_target_temperature_step = max(
136
+ self.device["thermostat"]["resolution"], 0.1
137
+ )
138
+
139
+ @override
140
+ async def async_added_to_hass(self) -> None:
141
+ """Run when entity about to be added."""
142
+ await super().async_added_to_hass()
143
+
144
+ if extra_data := await self.async_get_last_extra_data():
145
+ plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict(
146
+ extra_data.as_dict()
147
+ )
148
+ self._last_active_schedule = (
149
+ plugwise_extra_data.last_active_schedule or STATE_OFF
150
+ )
151
+ self._previous_action_mode = (
152
+ plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value
153
+ )
154
+
155
+ @property
156
+ @override
157
+ def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData:
158
+ """Return text specific state data to be restored."""
159
+ return PlugwiseClimateExtraStoredData(
160
+ last_active_schedule=self._last_active_schedule,
161
+ previous_action_mode=self._previous_action_mode,
162
  )
163
 
164
  @property
165
+ @override
166
+ def native_current_temperature(self) -> float | None:
167
  """Return the current temperature."""
168
+ return self.device["sensors"].get("temperature")
169
 
170
  @property
171
+ @override
172
+ def native_target_temperature(self) -> float:
173
  """Return the temperature we try to reach.
174
 
175
  Connected to the HVACMode combination of AUTO-HEAT.
 
178
  return self.device["thermostat"]["setpoint"]
179
 
180
  @property
181
+ @override
182
+ def native_target_temperature_high(self) -> float:
183
  """Return the temperature we try to reach in case of cooling.
184
 
185
  Connected to the HVACMode combination of AUTO-HEAT_COOL.
 
187
  return self.device["thermostat"]["setpoint_high"]
188
 
189
  @property
190
+ @override
191
+ def native_target_temperature_low(self) -> float:
192
  """Return the heating temperature we try to reach in case of heating.
193
 
194
  Connected to the HVACMode combination AUTO-HEAT_COOL.
 
196
  return self.device["thermostat"]["setpoint_low"]
197
 
198
  @property
199
+ @override
200
  def hvac_mode(self) -> HVACMode:
201
+ """Return HVAC operation ie. auto, cool, heat, heat_cool, or off mode."""
202
  if (
203
+ mode := self.device.get("climate_mode")
204
+ ) is None or mode not in self.hvac_modes:
205
+ return HVACMode.HEAT
 
 
 
 
206
  return HVACMode(mode)
207
 
208
  @property
209
+ @override
210
+ def hvac_modes(self) -> list[HVACMode]:
211
+ """Return a list of available HVACModes."""
212
+ hvac_modes: list[HVACMode] = []
213
+ if "regulation_modes" in self._gateway_data:
214
+ hvac_modes.append(HVACMode.OFF)
215
+
216
+ if self.device["available_schedules"] != [STATE_OFF]:
217
+ hvac_modes.append(HVACMode.AUTO)
218
+
219
+ if self._api.cooling_present:
220
+ if "regulation_modes" in self._gateway_data:
221
+ if "heating" in self._gateway_data["regulation_modes"]:
222
+ hvac_modes.append(HVACMode.HEAT)
223
+ if "cooling" in self._gateway_data["regulation_modes"]:
224
+ hvac_modes.append(HVACMode.COOL)
225
+ else:
226
+ hvac_modes.append(HVACMode.HEAT_COOL)
227
+ else:
228
+ hvac_modes.append(HVACMode.HEAT)
229
+
230
+ return hvac_modes
231
+
232
+ @property
233
+ @override
234
+ def hvac_action(self) -> HVACAction:
235
  """Return the current running hvac operation if supported."""
236
+ # Keep track of the previous hvac_action mode.
237
+ # When no cooling available, _previous_action_mode is always heating
238
+ if (
239
+ "regulation_modes" in self._gateway_data
240
+ and HVACAction.COOLING.value in self._gateway_data["regulation_modes"]
241
+ ):
242
+ mode = self._gateway_data["select_regulation_mode"]
243
+ if mode in (HVACAction.COOLING.value, HVACAction.HEATING.value):
244
+ self._previous_action_mode = mode
245
+
246
+ if (action := self.device.get("control_state")) is not None:
247
+ return HVACAction(action)
 
 
 
 
 
248
 
249
  return HVACAction.IDLE
250
 
251
  @property
252
+ @override
253
  def preset_mode(self) -> str | None:
254
  """Return the current preset mode."""
255
+ return self.device.get("active_preset")
256
 
257
  @plugwise_command
258
+ @override
259
  async def async_set_temperature(self, **kwargs: Any) -> None:
260
  """Set new target temperature."""
 
 
 
 
 
261
  data: dict[str, Any] = {}
262
  if ATTR_TEMPERATURE in kwargs:
263
  data["setpoint"] = kwargs.get(ATTR_TEMPERATURE)
 
266
  if ATTR_TARGET_TEMP_LOW in kwargs:
267
  data["setpoint_low"] = kwargs.get(ATTR_TARGET_TEMP_LOW)
268
 
269
+ if mode := kwargs.get(ATTR_HVAC_MODE):
270
+ await self.async_set_hvac_mode(mode)
 
 
 
271
 
272
+ await self._api.set_temperature(self._location, data)
273
+
274
+ def _regulation_mode_for_hvac(self, hvac_mode: HVACMode) -> str:
275
+ """Return the API regulation value for a manual HVAC mode, or None.
276
+
277
+ The function inputs are limited to the HVACModes HEAT and COOL.
278
+ """
279
+ if hvac_mode == HVACMode.HEAT:
280
+ mode = HVACAction.HEATING.value
281
+ if hvac_mode == HVACMode.COOL:
282
+ mode = HVACAction.COOLING.value
283
+ return mode
284
 
285
  @plugwise_command
286
+ @override
287
  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
288
+ """Set the HVAC mode (off, heat, cool, heat_cool, or auto/schedule)."""
289
+ # Early exit if no mode change
290
+ if hvac_mode == self.hvac_mode:
291
+ return
292
+
293
+ # Adam only: set to HVACMode.OFF
294
+ if hvac_mode == HVACMode.OFF:
295
+ await self._api.set_regulation_mode(hvac_mode.value)
296
+ return
297
+
298
+ current_schedule = self.device["select_schedule"]
299
+ schedule_is_active = current_schedule != STATE_OFF
300
+ desired_schedule = (
301
+ current_schedule if schedule_is_active else self._last_active_schedule
302
  )
303
+ # Adam only: transition from HVACMode.OFF
304
+ if self.hvac_mode == HVACMode.OFF:
305
+ if hvac_mode == HVACMode.AUTO:
306
+ _check_for_schedule(schedule_is_active, self._last_active_schedule)
307
+ await self._api.set_schedule_state(
308
+ self._location,
309
+ desired_schedule,
310
+ STATE_ON,
311
+ )
312
+ await self._api.set_regulation_mode(self._previous_action_mode)
313
+ return
314
+
315
+ # Transition to manual mode
316
+ if schedule_is_active:
317
+ await self._api.set_schedule_state(
318
+ self._location,
319
+ current_schedule,
320
+ STATE_OFF,
321
+ )
322
+ self._last_active_schedule = current_schedule
323
+ regulation = self._regulation_mode_for_hvac(hvac_mode)
324
+ await self._api.set_regulation_mode(regulation)
325
+ return
326
+
327
+ # Common - transition from auto = schedule off
328
+ if self.hvac_mode == HVACMode.AUTO:
329
+ await self._api.set_schedule_state(
330
+ self._location,
331
+ current_schedule,
332
+ STATE_OFF,
333
+ )
334
+ self._last_active_schedule = current_schedule
335
+ return
336
 
337
+ # Common - transition to auto = schedule on
338
+ _check_for_schedule(schedule_is_active, self._last_active_schedule)
339
+ await self._api.set_schedule_state(self._location, desired_schedule, STATE_ON)
 
 
 
 
 
 
 
340
 
341
  @plugwise_command
342
+ @override
343
  async def async_set_preset_mode(self, preset_mode: str) -> None:
344
  """Set the preset mode."""
345
+ await self._api.set_preset(self._location, preset_mode)
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/config_flow.py RENAMED
@@ -1,27 +1,8 @@
1
  """Config flow for Plugwise integration."""
2
- from __future__ import annotations
3
 
4
- import datetime as dt # pw-beta options
5
- from typing import Any
6
 
7
- import voluptuous as vol
8
-
9
- from homeassistant import config_entries
10
- from homeassistant.components.zeroconf import ZeroconfServiceInfo
11
- from homeassistant.config_entries import ConfigEntry, ConfigFlow
12
- from homeassistant.const import (
13
- CONF_BASE,
14
- CONF_HOST,
15
- CONF_NAME,
16
- CONF_PASSWORD,
17
- CONF_PORT,
18
- CONF_SCAN_INTERVAL,
19
- CONF_USERNAME,
20
- )
21
- from homeassistant.core import HomeAssistant, callback
22
- from homeassistant.data_entry_flow import FlowResult
23
- from homeassistant.helpers import config_validation as cv
24
- from homeassistant.helpers.aiohttp_client import async_get_clientsession
25
  from plugwise import Smile
26
  from plugwise.exceptions import (
27
  ConnectionFailedError,
@@ -31,59 +12,66 @@
31
  ResponseError,
32
  UnsupportedDeviceError,
33
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  from .const import (
36
- CONF_HOMEKIT_EMULATION, # pw-beta option
37
- CONF_REFRESH_INTERVAL, # pw-beta option
38
- COORDINATOR,
39
  DEFAULT_PORT,
40
- DEFAULT_SCAN_INTERVAL, # pw-beta option
41
  DEFAULT_USERNAME,
42
  DOMAIN,
43
  FLOW_SMILE,
44
  FLOW_STRETCH,
45
  SMILE,
 
 
46
  STRETCH,
47
  STRETCH_USERNAME,
 
48
  ZEROCONF_MAP,
49
  )
50
 
 
51
 
52
- def _base_gw_schema(
53
- discovery_info: ZeroconfServiceInfo | None,
54
- user_input: dict[str, Any] | None,
55
- ) -> vol.Schema:
 
 
 
 
56
  """Generate base schema for gateways."""
 
 
57
  if not discovery_info:
58
- if not user_input:
59
- return vol.Schema(
60
- {
61
- vol.Required(CONF_PASSWORD): str,
62
- vol.Required(CONF_HOST): str,
63
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
64
- vol.Required(CONF_USERNAME, default=SMILE): vol.In(
65
- {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH}
66
- ),
67
- }
68
- )
69
- return vol.Schema(
70
  {
71
- vol.Required(CONF_PASSWORD, default=user_input[CONF_PASSWORD]): str,
72
- vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str,
73
- vol.Optional(CONF_PORT, default=user_input[CONF_PORT]): int,
74
- vol.Required(CONF_USERNAME, default=user_input[CONF_USERNAME]): vol.In(
75
  {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH}
76
  ),
77
  }
78
  )
79
 
80
- return vol.Schema({vol.Required(CONF_PASSWORD): str})
81
 
82
 
83
- async def validate_gw_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile:
84
  """Validate whether the user input allows us to connect to the gateway.
85
 
86
- Data has the keys from _base_gw_schema() with values provided by the user.
87
  """
88
  websession = async_get_clientsession(hass, verify_ssl=False)
89
  api = Smile(
@@ -91,24 +79,51 @@
91
  password=data[CONF_PASSWORD],
92
  port=data[CONF_PORT],
93
  username=data[CONF_USERNAME],
94
- timeout=30,
95
  websession=websession,
96
  )
97
  await api.connect()
98
  return api
99
 
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN):
102
  """Handle a config flow for Plugwise Smile."""
103
 
104
  VERSION = 1
105
 
106
  discovery_info: ZeroconfServiceInfo | None = None
 
107
  _username: str = DEFAULT_USERNAME
108
 
 
109
  async def async_step_zeroconf(
110
  self, discovery_info: ZeroconfServiceInfo
111
- ) -> FlowResult:
112
  """Prepare configuration for a discovered Plugwise Smile."""
113
  self.discovery_info = discovery_info
114
  _properties = discovery_info.properties
@@ -116,7 +131,7 @@
116
  unique_id = discovery_info.hostname.split(".")[0].split("-")[0]
117
  if config_entry := await self.async_set_unique_id(unique_id):
118
  try:
119
- await validate_gw_input(
120
  self.hass,
121
  {
122
  CONF_HOST: discovery_info.host,
@@ -125,7 +140,7 @@
125
  CONF_PASSWORD: config_entry.data[CONF_PASSWORD],
126
  },
127
  )
128
- except Exception: # pylint: disable=broad-except
129
  self._abort_if_unique_id_configured()
130
  else:
131
  self._abort_if_unique_id_configured(
@@ -137,165 +152,108 @@
137
 
138
  if DEFAULT_USERNAME not in unique_id:
139
  self._username = STRETCH_USERNAME
140
- _product = _properties.get("product", None)
141
  _version = _properties.get("version", "n/a")
142
  _name = f"{ZEROCONF_MAP.get(_product, _product)} v{_version}"
143
 
144
  # This is an Anna, but we already have config entries.
145
  # Assuming that the user has already configured Adam, aborting discovery.
146
- if self._async_current_entries() and _product == "smile_thermo":
147
- return self.async_abort(reason="anna_with_adam")
148
 
149
  # If we have discovered an Adam or Anna, both might be on the network.
150
  # In that case, we need to cancel the Anna flow, as the Adam should
151
  # be added.
152
- for flow in self._async_in_progress():
153
- # This is an Anna, and there is already an Adam flow in progress
154
- if (
155
- _product == "smile_thermo"
156
- and "context" in flow
157
- and flow["context"].get("product") == "smile_open_therm"
158
- ):
159
- return self.async_abort(reason="anna_with_adam")
160
-
161
- # This is an Adam, and there is already an Anna flow in progress
162
- if (
163
- _product == "smile_open_therm"
164
- and "context" in flow
165
- and flow["context"].get("product") == "smile_thermo"
166
- and "flow_id" in flow
167
- ):
168
- self.hass.config_entries.flow.async_abort(flow["flow_id"])
169
 
170
  self.context.update(
171
  {
172
- "title_placeholders": {
173
- CONF_HOST: discovery_info.host,
174
- CONF_NAME: _name,
175
- CONF_PORT: discovery_info.port,
176
- CONF_USERNAME: self._username,
177
- },
178
- "configuration_url": f"http://{discovery_info.host}:{discovery_info.port}",
179
- "product": _product,
180
  }
181
  )
182
  return await self.async_step_user()
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  async def async_step_user(
185
  self, user_input: dict[str, Any] | None = None
186
- ) -> FlowResult:
187
  """Handle the initial step when using network/gateway setups."""
188
  errors: dict[str, str] = {}
189
 
190
- if not user_input:
191
- return self.async_show_form(
192
- step_id="user",
193
- data_schema=_base_gw_schema(self.discovery_info, None),
194
- errors=errors,
195
- )
196
-
197
- if self.discovery_info:
198
- user_input[CONF_HOST] = self.discovery_info.host
199
- user_input[CONF_PORT] = self.discovery_info.port
200
- user_input[CONF_USERNAME] = self._username
201
- try:
202
- api = await validate_gw_input(self.hass, user_input)
203
- except ConnectionFailedError:
204
- errors[CONF_BASE] = "cannot_connect"
205
- except InvalidAuthentication:
206
- errors[CONF_BASE] = "invalid_auth"
207
- except InvalidSetupError:
208
- errors[CONF_BASE] = "invalid_setup"
209
- except (InvalidXMLError, ResponseError):
210
- errors[CONF_BASE] = "response_error"
211
- except UnsupportedDeviceError:
212
- errors[CONF_BASE] = "unsupported"
213
- except Exception: # pylint: disable=broad-except
214
- errors[CONF_BASE] = "unknown"
215
-
216
- if errors:
217
- return self.async_show_form(
218
- step_id="user",
219
- data_schema=_base_gw_schema(None, user_input),
220
- errors=errors,
221
- )
222
-
223
- await self.async_set_unique_id(
224
- api.smile_hostname or api.gateway_id, raise_on_progress=False
225
- )
226
- self._abort_if_unique_id_configured()
227
-
228
- return self.async_create_entry(title=api.smile_name, data=user_input)
229
-
230
- @staticmethod
231
- @callback
232
- def async_get_options_flow(
233
- config_entry: ConfigEntry,
234
- ) -> config_entries.OptionsFlow: # pw-beta options
235
- """Get the options flow for this handler."""
236
- return PlugwiseOptionsFlowHandler(config_entry)
237
-
238
-
239
- # pw-beta - change the scan-interval via CONFIGURE
240
- # pw-beta - add homekit emulation via CONFIGURE
241
- # pw-beta - change the frontend refresh interval via CONFIGURE
242
- class PlugwiseOptionsFlowHandler(config_entries.OptionsFlow): # pw-beta options
243
- """Plugwise option flow."""
244
-
245
- def __init__(self, config_entry: ConfigEntry) -> None: # pragma: no cover
246
- """Initialize options flow."""
247
- self.config_entry = config_entry
248
-
249
- async def async_step_none(
250
- self, user_input: dict[str, Any] | None = None
251
- ) -> FlowResult: # pragma: no cover
252
- """No options available."""
253
  if user_input is not None:
254
- # Apparently not possible to abort an options flow at the moment
255
- return self.async_create_entry(title="", data=self.config_entry.options)
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
- return self.async_show_form(step_id="none")
 
 
 
 
258
 
259
- async def async_step_init(
260
  self, user_input: dict[str, Any] | None = None
261
- ) -> FlowResult: # pragma: no cover
262
- """Manage the Plugwise options."""
263
- if not self.config_entry.data.get(CONF_HOST):
264
- return await self.async_step_none(user_input)
265
-
266
- if user_input is not None:
267
- return self.async_create_entry(title="", data=user_input)
268
-
269
- coordinator = self.hass.data[DOMAIN][self.config_entry.entry_id][COORDINATOR]
270
- interval: dt.timedelta = DEFAULT_SCAN_INTERVAL[
271
- coordinator.api.smile_type
272
- ] # pw-beta options
273
-
274
- data = {
275
- vol.Optional(
276
- CONF_SCAN_INTERVAL,
277
- default=self.config_entry.options.get(
278
- CONF_SCAN_INTERVAL, interval.seconds
279
- ),
280
- ): vol.All(cv.positive_int, vol.Clamp(min=10)),
281
- } # pw-beta
282
 
283
- if coordinator.api.smile_type != "thermostat":
284
- return self.async_show_form(step_id="init", data_schema=vol.Schema(data))
285
 
286
- data.update(
287
- {
288
- vol.Optional(
289
- CONF_HOMEKIT_EMULATION,
290
- default=self.config_entry.options.get(
291
- CONF_HOMEKIT_EMULATION, False
292
- ),
293
- ): cv.boolean,
294
- vol.Optional(
295
- CONF_REFRESH_INTERVAL,
296
- default=self.config_entry.options.get(CONF_REFRESH_INTERVAL, 1.5),
297
- ): vol.All(vol.Coerce(float), vol.Range(min=1.5, max=10.0)),
298
  }
299
- ) # pw-beta
300
 
301
- return self.async_show_form(step_id="init", data_schema=vol.Schema(data))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Config flow for Plugwise integration."""
 
2
 
3
+ import logging
4
+ from typing import Any, Self, override
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from plugwise import Smile
7
  from plugwise.exceptions import (
8
  ConnectionFailedError,
 
12
  ResponseError,
13
  UnsupportedDeviceError,
14
  )
15
+ import probatio
16
+
17
+ from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
18
+ from homeassistant.const import (
19
+ CONF_BASE,
20
+ CONF_HOST,
21
+ CONF_PASSWORD,
22
+ CONF_PORT,
23
+ CONF_USERNAME,
24
+ )
25
+ from homeassistant.core import HomeAssistant
26
+ from homeassistant.helpers.aiohttp_client import async_get_clientsession
27
+ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
28
 
29
  from .const import (
30
+ ANNA_WITH_ADAM,
 
 
31
  DEFAULT_PORT,
 
32
  DEFAULT_USERNAME,
33
  DOMAIN,
34
  FLOW_SMILE,
35
  FLOW_STRETCH,
36
  SMILE,
37
+ SMILE_OPEN_THERM,
38
+ SMILE_THERMO,
39
  STRETCH,
40
  STRETCH_USERNAME,
41
+ UNKNOWN_SMILE,
42
  ZEROCONF_MAP,
43
  )
44
 
45
+ _LOGGER = logging.getLogger(__name__)
46
 
47
+ SMILE_RECONF_SCHEMA = probatio.Schema(
48
+ {
49
+ probatio.Required(CONF_HOST): str,
50
+ }
51
+ )
52
+
53
+
54
+ def smile_user_schema(discovery_info: ZeroconfServiceInfo | None) -> probatio.Schema:
55
  """Generate base schema for gateways."""
56
+ schema = probatio.Schema({probatio.Required(probatio.Secret(CONF_PASSWORD)): str})
57
+
58
  if not discovery_info:
59
+ schema = schema.extend(
 
 
 
 
 
 
 
 
 
 
 
60
  {
61
+ probatio.Required(CONF_HOST): str,
62
+ probatio.Required(CONF_USERNAME, default=SMILE): probatio.In(
 
 
63
  {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH}
64
  ),
65
  }
66
  )
67
 
68
+ return schema
69
 
70
 
71
+ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile:
72
  """Validate whether the user input allows us to connect to the gateway.
73
 
74
+ Data has the keys from the schema with values provided by the user.
75
  """
76
  websession = async_get_clientsession(hass, verify_ssl=False)
77
  api = Smile(
 
79
  password=data[CONF_PASSWORD],
80
  port=data[CONF_PORT],
81
  username=data[CONF_USERNAME],
 
82
  websession=websession,
83
  )
84
  await api.connect()
85
  return api
86
 
87
 
88
+ async def verify_connection(
89
+ hass: HomeAssistant, user_input: dict[str, Any]
90
+ ) -> tuple[Smile | None, dict[str, str]]:
91
+ """Verify and return the gateway connection or an error."""
92
+ errors: dict[str, str] = {}
93
+
94
+ try:
95
+ return (await validate_input(hass, user_input), errors)
96
+ except ConnectionFailedError:
97
+ errors[CONF_BASE] = "cannot_connect"
98
+ except InvalidAuthentication:
99
+ errors[CONF_BASE] = "invalid_auth"
100
+ except InvalidSetupError:
101
+ errors[CONF_BASE] = "invalid_setup"
102
+ except InvalidXMLError, ResponseError:
103
+ errors[CONF_BASE] = "response_error"
104
+ except UnsupportedDeviceError:
105
+ errors[CONF_BASE] = "unsupported"
106
+ except Exception:
107
+ _LOGGER.exception(
108
+ "Unknown exception while verifying connection with your Plugwise Smile"
109
+ )
110
+ errors[CONF_BASE] = "unknown"
111
+ return (None, errors)
112
+
113
+
114
  class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN):
115
  """Handle a config flow for Plugwise Smile."""
116
 
117
  VERSION = 1
118
 
119
  discovery_info: ZeroconfServiceInfo | None = None
120
+ product: str = UNKNOWN_SMILE
121
  _username: str = DEFAULT_USERNAME
122
 
123
+ @override
124
  async def async_step_zeroconf(
125
  self, discovery_info: ZeroconfServiceInfo
126
+ ) -> ConfigFlowResult:
127
  """Prepare configuration for a discovered Plugwise Smile."""
128
  self.discovery_info = discovery_info
129
  _properties = discovery_info.properties
 
131
  unique_id = discovery_info.hostname.split(".")[0].split("-")[0]
132
  if config_entry := await self.async_set_unique_id(unique_id):
133
  try:
134
+ await validate_input(
135
  self.hass,
136
  {
137
  CONF_HOST: discovery_info.host,
 
140
  CONF_PASSWORD: config_entry.data[CONF_PASSWORD],
141
  },
142
  )
143
+ except Exception: # noqa: BLE001
144
  self._abort_if_unique_id_configured()
145
  else:
146
  self._abort_if_unique_id_configured(
 
152
 
153
  if DEFAULT_USERNAME not in unique_id:
154
  self._username = STRETCH_USERNAME
155
+ self.product = _product = _properties.get("product", UNKNOWN_SMILE)
156
  _version = _properties.get("version", "n/a")
157
  _name = f"{ZEROCONF_MAP.get(_product, _product)} v{_version}"
158
 
159
  # This is an Anna, but we already have config entries.
160
  # Assuming that the user has already configured Adam, aborting discovery.
161
+ if self._async_current_entries() and _product == SMILE_THERMO:
162
+ return self.async_abort(reason=ANNA_WITH_ADAM)
163
 
164
  # If we have discovered an Adam or Anna, both might be on the network.
165
  # In that case, we need to cancel the Anna flow, as the Adam should
166
  # be added.
167
+ if self.hass.config_entries.flow.async_has_matching_flow(self):
168
+ return self.async_abort(reason=ANNA_WITH_ADAM)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  self.context.update(
171
  {
172
+ "title_placeholders": {"name": _name},
173
+ "configuration_url": (
174
+ f"http://{discovery_info.host}:{discovery_info.port}"
175
+ ),
 
 
 
 
176
  }
177
  )
178
  return await self.async_step_user()
179
 
180
+ @override
181
+ def is_matching(self, other_flow: Self) -> bool:
182
+ """Return True if other_flow is matching this flow."""
183
+ # This is an Anna, and there is already an Adam flow in progress
184
+ if self.product == SMILE_THERMO and other_flow.product == SMILE_OPEN_THERM:
185
+ return True
186
+
187
+ # This is an Adam, and there is already an Anna flow in progress
188
+ if self.product == SMILE_OPEN_THERM and other_flow.product == SMILE_THERMO:
189
+ self.hass.config_entries.flow.async_abort(other_flow.flow_id)
190
+
191
+ return False
192
+
193
+ @override
194
  async def async_step_user(
195
  self, user_input: dict[str, Any] | None = None
196
+ ) -> ConfigFlowResult:
197
  """Handle the initial step when using network/gateway setups."""
198
  errors: dict[str, str] = {}
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  if user_input is not None:
201
+ user_input[CONF_PORT] = DEFAULT_PORT
202
+ if self.discovery_info:
203
+ user_input[CONF_HOST] = self.discovery_info.host
204
+ user_input[CONF_PORT] = self.discovery_info.port
205
+ user_input[CONF_USERNAME] = self._username
206
+
207
+ api, errors = await verify_connection(self.hass, user_input)
208
+ if api:
209
+ await self.async_set_unique_id(
210
+ api.smile.hostname or api.gateway_id,
211
+ raise_on_progress=False,
212
+ )
213
+ self._abort_if_unique_id_configured()
214
+ return self.async_create_entry(title=api.smile.name, data=user_input)
215
 
216
+ return self.async_show_form(
217
+ step_id=SOURCE_USER,
218
+ data_schema=smile_user_schema(self.discovery_info),
219
+ errors=errors,
220
+ )
221
 
222
+ async def async_step_reconfigure(
223
  self, user_input: dict[str, Any] | None = None
224
+ ) -> ConfigFlowResult:
225
+ """Handle reconfiguration of the integration."""
226
+ errors: dict[str, str] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
+ reconfigure_entry = self._get_reconfigure_entry()
 
229
 
230
+ if user_input:
231
+ # Keep current username and password
232
+ full_input = {
233
+ CONF_HOST: user_input.get(CONF_HOST),
234
+ CONF_PORT: reconfigure_entry.data.get(CONF_PORT),
235
+ CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME),
236
+ CONF_PASSWORD: reconfigure_entry.data.get(CONF_PASSWORD),
 
 
 
 
 
237
  }
 
238
 
239
+ api, errors = await verify_connection(self.hass, full_input)
240
+ if api:
241
+ await self.async_set_unique_id(
242
+ api.smile.hostname or api.gateway_id,
243
+ raise_on_progress=False,
244
+ )
245
+ self._abort_if_unique_id_mismatch(reason="not_the_same_smile")
246
+ return self.async_update_reload_and_abort(
247
+ reconfigure_entry,
248
+ data_updates=full_input,
249
+ )
250
+
251
+ return self.async_show_form(
252
+ step_id="reconfigure",
253
+ data_schema=self.add_suggested_values_to_schema(
254
+ data_schema=SMILE_RECONF_SCHEMA,
255
+ suggested_values=reconfigure_entry.data,
256
+ ),
257
+ description_placeholders={"title": reconfigure_entry.title},
258
+ errors=errors,
259
+ )
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/const.py RENAMED
@@ -1,7 +1,8 @@
1
  """Constants for Plugwise component."""
 
2
  from datetime import timedelta
3
  import logging
4
- from typing import Final
5
 
6
  from homeassistant.const import Platform
7
 
@@ -9,57 +10,121 @@
9
 
10
  LOGGER = logging.getLogger(__package__)
11
 
 
12
  API: Final = "api"
13
- COORDINATOR: Final = "coordinator"
14
- CONF_HOMEKIT_EMULATION: Final = "homekit_emulation" # pw-beta options
15
- CONF_REFRESH_INTERVAL: Final = "refresh_interval" # pw-beta options
16
- CONF_MANUAL_PATH: Final = "Enter Manually"
 
 
 
 
 
 
17
  SMILE: Final = "smile"
 
 
18
  STRETCH: Final = "stretch"
19
  STRETCH_USERNAME: Final = "stretch"
 
 
20
 
21
- FLOW_NET: Final = "Network: Smile/Stretch"
22
- FLOW_SMILE: Final = "Smile (Adam/Anna/P1)"
23
- FLOW_STRETCH: Final = "Stretch (Stretch)"
24
- FLOW_TYPE: Final = "flow_type"
25
-
26
- UNDO_UPDATE_LISTENER: Final = "undo_update_listener"
27
-
28
- # Default directives
29
- DEFAULT_PORT: Final = 80
30
- DEFAULT_SCAN_INTERVAL: Final[dict[str, timedelta]] = {
31
- "power": timedelta(seconds=10),
32
- "stretch": timedelta(seconds=60),
33
- "thermostat": timedelta(seconds=60),
34
- }
35
- DEFAULT_TIMEOUT: Final = 10
36
- DEFAULT_USERNAME: Final = "smile"
37
-
38
- # --- Const for Plugwise Smile and Stretch
39
- PLATFORMS_GATEWAY: Final[list[str]] = [
40
  Platform.BINARY_SENSOR,
 
41
  Platform.CLIMATE,
42
  Platform.NUMBER,
43
  Platform.SELECT,
44
  Platform.SENSOR,
45
  Platform.SWITCH,
 
46
  ]
47
- SENSOR_PLATFORMS: Final[list[str]] = [Platform.SENSOR, Platform.SWITCH]
48
- SERVICE_DELETE: Final = "delete_notification"
49
- SEVERITIES: Final[list[str]] = ["other", "info", "message", "warning", "error"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
- # Climate const:
52
  MASTER_THERMOSTATS: Final[list[str]] = [
53
  "thermostat",
 
54
  "zone_thermometer",
55
  "zone_thermostat",
56
- "thermostatic_radiator_valve",
57
  ]
58
 
59
- # Config_flow const:
60
- ZEROCONF_MAP: Final[dict[str, str]] = {
61
- "smile": "Smile P1",
62
- "smile_thermo": "Smile Anna",
63
- "smile_open_therm": "Adam",
64
- "stretch": "Stretch",
65
- }
 
 
 
 
1
  """Constants for Plugwise component."""
2
+
3
  from datetime import timedelta
4
  import logging
5
+ from typing import Final, Literal, get_args
6
 
7
  from homeassistant.const import Platform
8
 
 
10
 
11
  LOGGER = logging.getLogger(__package__)
12
 
13
+ ANNA_WITH_ADAM: Final = "anna_with_adam"
14
  API: Final = "api"
15
+ AVAILABLE: Final = "available"
16
+ DEV_CLASS: Final = "dev_class"
17
+ FLOW_SMILE: Final = "smile (Adam/Anna/P1)"
18
+ FLOW_STRETCH: Final = "stretch (Stretch)"
19
+ FLOW_TYPE: Final = "flow_type"
20
+ GATEWAY: Final = "gateway"
21
+ LOCATION: Final = "location"
22
+ LOWER_BOUND: Final = "lower_bound"
23
+ PW_TYPE: Final = "plugwise_type"
24
+ REBOOT: Final = "reboot"
25
  SMILE: Final = "smile"
26
+ SMILE_OPEN_THERM: Final = "smile_open_therm"
27
+ SMILE_THERMO: Final = "smile_thermo"
28
  STRETCH: Final = "stretch"
29
  STRETCH_USERNAME: Final = "stretch"
30
+ UNKNOWN_SMILE: Final = "Unknown Smile"
31
+ UPPER_BOUND: Final = "upper_bound"
32
 
33
+ PLATFORMS: Final[list[str]] = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  Platform.BINARY_SENSOR,
35
+ Platform.BUTTON,
36
  Platform.CLIMATE,
37
  Platform.NUMBER,
38
  Platform.SELECT,
39
  Platform.SENSOR,
40
  Platform.SWITCH,
41
+ Platform.WATER_HEATER,
42
  ]
43
+ ZEROCONF_MAP: Final[dict[str, str]] = {
44
+ "smile": "Smile P1",
45
+ "smile_thermo": "Smile Anna",
46
+ "smile_open_therm": "Adam",
47
+ "stretch": "Stretch",
48
+ }
49
+
50
+ type BinarySensorType = Literal["dhw_state"]
51
+
52
+ type NumberDataType = Literal[
53
+ "asleep_preset_temperature",
54
+ "away_preset_temperature",
55
+ "boiler_temperature",
56
+ "dhw_temperature",
57
+ "home_preset_temperature",
58
+ "no_frost_preset_temperature",
59
+ "temperature_offset",
60
+ "vacation_preset_temperature",
61
+ ]
62
+
63
+ type PresetTemperatureType = Literal[
64
+ "asleep_preset_temperature",
65
+ "away_preset_temperature",
66
+ "home_preset_temperature",
67
+ "no_frost_preset_temperature",
68
+ "vacation_preset_temperature",
69
+ ]
70
+ PRESET_TEMPERATURE_KEYS: Final[tuple[PresetTemperatureType, ...]] = get_args(
71
+ PresetTemperatureType.__value__
72
+ )
73
+
74
+ type PresetCoolingTemperatureType = Literal[
75
+ "asleep_preset_cooling_temperature",
76
+ "away_preset_cooling_temperature",
77
+ "home_preset_cooling_temperature",
78
+ "no_frost_preset_cooling_temperature",
79
+ "vacation_preset_cooling_temperature",
80
+ ]
81
+ PRESET_COOLING_TEMPERATURE_KEYS: Final[tuple[PresetCoolingTemperatureType, ...]] = (
82
+ get_args(PresetCoolingTemperatureType.__value__)
83
+ )
84
+ type NumberType = NumberDataType | PresetCoolingTemperatureType
85
+
86
+ type SelectType = Literal[
87
+ "dhw_mode",
88
+ "select_dhw_mode",
89
+ "select_gateway_mode",
90
+ "select_regulation_mode",
91
+ "select_schedule",
92
+ "select_zone_profile",
93
+ ]
94
+ type SelectOptionsType = Literal[
95
+ "available_schedules",
96
+ "dhw_modes",
97
+ "gateway_modes",
98
+ "regulation_modes",
99
+ "zone_profiles",
100
+ ]
101
+
102
+ type SetpointType = Literal["setpoint", "setpoint_high", "setpoint_low"]
103
+
104
+ type WaterHeaterType = Literal["dhw_temperature"]
105
+
106
+ # Default directives
107
+ DEFAULT_MAX_TEMP: Final = 30
108
+ DEFAULT_MIN_TEMP: Final = 4
109
+ DEFAULT_PORT: Final = 80
110
+ DEFAULT_UPDATE_INTERVAL = timedelta(seconds=60)
111
+ DEFAULT_USERNAME: Final = "smile"
112
+ P1_UPDATE_INTERVAL = timedelta(seconds=10)
113
 
 
114
  MASTER_THERMOSTATS: Final[list[str]] = [
115
  "thermostat",
116
+ "thermostatic_radiator_valve",
117
  "zone_thermometer",
118
  "zone_thermostat",
 
119
  ]
120
 
121
+ # Select constants
122
+ DHW_MODE: Final = "dhw_mode"
123
+ SELECT_DHW_MODE: Final = "select_dhw_mode"
124
+ SELECT_GATEWAY_MODE: Final = "select_gateway_mode"
125
+ SELECT_REGULATION_MODE: Final = "select_regulation_mode"
126
+ SELECT_SCHEDULE: Final = "select_schedule"
127
+ SELECT_ZONE_PROFILE: Final = "select_zone_profile"
128
+
129
+ # Water_heater constants
130
+ DHW_TEMP: Final = "dhw_temperature"
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/coordinator.py RENAMED
@@ -1,115 +1,213 @@
1
  """DataUpdateCoordinator for Plugwise."""
2
- from datetime import timedelta
3
 
4
- from homeassistant.config_entries import ConfigEntry
5
- from homeassistant.const import (
6
- CONF_HOST,
7
- CONF_PASSWORD,
8
- CONF_PORT,
9
- CONF_SCAN_INTERVAL, # pw-beta options
10
- CONF_USERNAME,
11
- )
12
- from homeassistant.core import HomeAssistant
13
- from homeassistant.exceptions import ConfigEntryError
14
- from homeassistant.helpers.aiohttp_client import async_get_clientsession
15
- from homeassistant.helpers.debounce import Debouncer
16
- from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
17
- from plugwise import PlugwiseData, Smile
18
  from plugwise.exceptions import (
19
  ConnectionFailedError,
20
  InvalidAuthentication,
 
21
  InvalidXMLError,
 
22
  ResponseError,
23
  UnsupportedDeviceError,
24
  )
25
 
26
- from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_USERNAME, DOMAIN, LOGGER
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
 
28
 
29
- class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]):
 
30
  """Class to manage fetching Plugwise data from single endpoint."""
31
 
32
- _connected: bool = False
33
 
34
- def __init__(
35
- self,
36
- hass: HomeAssistant,
37
- entry: ConfigEntry,
38
- cooldown: float,
39
- update_interval: timedelta = timedelta(seconds=60),
40
- ) -> None: # pw-beta cooldown
41
  """Initialize the coordinator."""
42
  super().__init__(
43
  hass,
44
  LOGGER,
 
45
  name=DOMAIN,
46
- # Core directly updates from const's DEFAULT_SCAN_INTERVAL
47
- update_interval=update_interval,
48
  # Don't refresh immediately, give the device time to process
49
  # the change in state before we query it.
50
  request_refresh_debouncer=Debouncer(
51
  hass,
52
  LOGGER,
53
- cooldown=cooldown,
54
  immediate=False,
55
  ),
56
  )
57
 
58
  self.api = Smile(
59
- host=entry.data[CONF_HOST],
60
- username=entry.data.get(CONF_USERNAME, DEFAULT_USERNAME),
61
- password=entry.data[CONF_PASSWORD],
62
- port=entry.data.get(CONF_PORT, DEFAULT_PORT),
63
- timeout=30,
64
  websession=async_get_clientsession(hass, verify_ssl=False),
65
  )
66
- self._entry = entry
67
- self._unavailable_logged = False
68
- self.update_interval = update_interval
 
 
69
 
70
  async def _connect(self) -> None:
71
- """Connect to the Plugwise Smile."""
72
- self._connected = await self.api.connect()
73
- self.api.get_all_devices()
74
-
75
- self.update_interval = DEFAULT_SCAN_INTERVAL.get(
76
- self.api.smile_type, timedelta(seconds=60)
77
- ) # pw-beta options scan-interval
78
- if (custom_time := self._entry.options.get(CONF_SCAN_INTERVAL)) is not None:
79
- self.update_interval = timedelta(
80
- seconds=int(custom_time)
81
- ) # pragma: no cover # pw-beta options
82
 
83
- LOGGER.debug("DUC update interval: %s", self.update_interval) # pw-beta options
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
- async def _async_update_data(self) -> PlugwiseData:
 
86
  """Fetch data from Plugwise."""
87
- data = PlugwiseData(gateway={}, devices={})
88
-
89
  try:
90
  if not self._connected:
91
  await self._connect()
92
  data = await self.api.async_update()
93
- LOGGER.debug(f"{self.api.smile_name} data: %s", data)
94
- if self._unavailable_logged:
95
- self._unavailable_logged = False
 
 
96
  except InvalidAuthentication as err:
97
- if not self._unavailable_logged: # pw-beta add to Core
98
- self._unavailable_logged = True
99
- raise ConfigEntryError("Authentication failed") from err
 
 
 
 
 
 
100
  except (InvalidXMLError, ResponseError) as err:
101
- if not self._unavailable_logged: # pw-beta add to Core
102
- self._unavailable_logged = True
103
- raise UpdateFailed(
104
- "Invalid XML data, or error indication received from the Plugwise Adam/Smile/Stretch"
105
- ) from err
 
 
 
 
106
  except UnsupportedDeviceError as err:
107
- if not self._unavailable_logged: # pw-beta add to Core
108
- self._unavailable_logged = True
109
- raise ConfigEntryError("Device with unsupported firmware") from err
110
- except ConnectionFailedError as err:
111
- if not self._unavailable_logged: # pw-beta add to Core
112
- self._unavailable_logged = True
113
- raise UpdateFailed("Failed to connect") from err
114
 
 
 
115
  return data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """DataUpdateCoordinator for Plugwise."""
 
2
 
3
+ from typing import override
4
+
5
+ from packaging.version import Version
6
+ from plugwise import GwEntityData, Smile
 
 
 
 
 
 
 
 
 
 
7
  from plugwise.exceptions import (
8
  ConnectionFailedError,
9
  InvalidAuthentication,
10
+ InvalidSetupError,
11
  InvalidXMLError,
12
+ PlugwiseError,
13
  ResponseError,
14
  UnsupportedDeviceError,
15
  )
16
 
17
+ from homeassistant.config_entries import ConfigEntry
18
+ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
19
+ from homeassistant.core import HomeAssistant
20
+ from homeassistant.exceptions import ConfigEntryError
21
+ from homeassistant.helpers import device_registry as dr
22
+ from homeassistant.helpers.aiohttp_client import async_get_clientsession
23
+ from homeassistant.helpers.debounce import Debouncer
24
+ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
25
+
26
+ from .const import (
27
+ DEFAULT_PORT,
28
+ DEFAULT_UPDATE_INTERVAL,
29
+ DEFAULT_USERNAME,
30
+ DOMAIN,
31
+ LOGGER,
32
+ P1_UPDATE_INTERVAL,
33
+ )
34
 
35
+ type PlugwiseConfigEntry = ConfigEntry[PlugwiseDataUpdateCoordinator]
36
 
37
+
38
+ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData]]):
39
  """Class to manage fetching Plugwise data from single endpoint."""
40
 
41
+ config_entry: PlugwiseConfigEntry
42
 
43
+ def __init__(self, hass: HomeAssistant, config_entry: PlugwiseConfigEntry) -> None:
 
 
 
 
 
 
44
  """Initialize the coordinator."""
45
  super().__init__(
46
  hass,
47
  LOGGER,
48
+ config_entry=config_entry,
49
  name=DOMAIN,
50
+ update_interval=DEFAULT_UPDATE_INTERVAL,
 
51
  # Don't refresh immediately, give the device time to process
52
  # the change in state before we query it.
53
  request_refresh_debouncer=Debouncer(
54
  hass,
55
  LOGGER,
56
+ cooldown=1.5,
57
  immediate=False,
58
  ),
59
  )
60
 
61
  self.api = Smile(
62
+ host=self.config_entry.data[CONF_HOST],
63
+ username=self.config_entry.data.get(CONF_USERNAME, DEFAULT_USERNAME),
64
+ password=self.config_entry.data[CONF_PASSWORD],
65
+ port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT),
 
66
  websession=async_get_clientsession(hass, verify_ssl=False),
67
  )
68
+ self._connected: bool = False
69
+ self._current_devices: set[str] = set()
70
+ self._firmware_list: dict[str, str | None] = {}
71
+ self._stored_devices: set[str] = set()
72
+ self.new_devices: set[str] = set()
73
 
74
  async def _connect(self) -> None:
75
+ """Connect to the Plugwise Smile.
 
 
 
 
 
 
 
 
 
 
76
 
77
+ A Version object is received when the connection succeeds.
78
+ """
79
+ version = await self.api.connect()
80
+ self._connected = isinstance(version, Version)
81
+ if self._connected and self.api.smile.type == "power":
82
+ self.update_interval = P1_UPDATE_INTERVAL
83
+
84
+ @override
85
+ async def _async_setup(self) -> None:
86
+ """Initialize the update_data process."""
87
+ device_reg = dr.async_get(self.hass)
88
+ device_entries = dr.async_entries_for_config_entry(
89
+ device_reg, self.config_entry.entry_id
90
+ )
91
+ self._stored_devices = {
92
+ identifier[1]
93
+ for device_entry in device_entries
94
+ for identifier in device_entry.identifiers
95
+ if identifier[0] == DOMAIN
96
+ }
97
 
98
+ @override
99
+ async def _async_update_data(self) -> dict[str, GwEntityData]:
100
  """Fetch data from Plugwise."""
 
 
101
  try:
102
  if not self._connected:
103
  await self._connect()
104
  data = await self.api.async_update()
105
+ except ConnectionFailedError as err:
106
+ raise UpdateFailed(
107
+ translation_domain=DOMAIN,
108
+ translation_key="failed_to_connect",
109
+ ) from err
110
  except InvalidAuthentication as err:
111
+ raise ConfigEntryError(
112
+ translation_domain=DOMAIN,
113
+ translation_key="authentication_failed",
114
+ ) from err
115
+ except InvalidSetupError as err:
116
+ raise ConfigEntryError(
117
+ translation_domain=DOMAIN,
118
+ translation_key="invalid_setup",
119
+ ) from err
120
  except (InvalidXMLError, ResponseError) as err:
121
+ raise UpdateFailed(
122
+ translation_domain=DOMAIN,
123
+ translation_key="response_error",
124
+ ) from err
125
+ except PlugwiseError as err:
126
+ raise UpdateFailed(
127
+ translation_domain=DOMAIN,
128
+ translation_key="data_incomplete_or_missing",
129
+ ) from err
130
  except UnsupportedDeviceError as err:
131
+ raise ConfigEntryError(
132
+ translation_domain=DOMAIN,
133
+ translation_key="unsupported_firmware",
134
+ ) from err
 
 
 
135
 
136
+ self._add_remove_devices(data)
137
+ self._update_device_firmware(data)
138
  return data
139
+
140
+ def _add_remove_devices(self, data: dict[str, GwEntityData]) -> None:
141
+ """Add new Plugwise devices, remove non-existing devices."""
142
+ set_of_data = set(data)
143
+ # Check for new or removed devices,
144
+ # 'new_devices' contains all devices present in 'data'
145
+ # at init ('self._current_devices' is empty) this is
146
+ # required for the proper initialization of all the
147
+ # present platform entities.
148
+ self.new_devices = set_of_data - self._current_devices
149
+ for device_id in self.new_devices:
150
+ self._firmware_list.setdefault(device_id, data[device_id].get("firmware"))
151
+
152
+ current_devices = (
153
+ self._stored_devices if not self._current_devices else self._current_devices
154
+ )
155
+ self._current_devices = set_of_data
156
+ if removed_devices := (current_devices - set_of_data): # device(s) to remove
157
+ self._remove_devices(removed_devices)
158
+
159
+ def _remove_devices(self, removed_devices: set[str]) -> None:
160
+ """Clean registries when removed devices found."""
161
+ device_reg = dr.async_get(self.hass)
162
+ for device_id in removed_devices:
163
+ if (
164
+ device_entry := device_reg.async_get_device_by_identifier(
165
+ (DOMAIN, device_id), self.config_entry.entry_id
166
+ )
167
+ ) is not None:
168
+ device_reg.async_remove_device(device_entry.id)
169
+ LOGGER.debug(
170
+ "%s %s %s removed from device_registry",
171
+ DOMAIN,
172
+ device_entry.model,
173
+ device_id,
174
+ )
175
+
176
+ self._firmware_list.pop(device_id, None)
177
+
178
+ def _update_device_firmware(self, data: dict[str, GwEntityData]) -> None:
179
+ """Detect firmware changes and update the device registry."""
180
+ for device_id, device in data.items():
181
+ # Only update firmware when the key is present and not None, to avoid
182
+ # wiping stored firmware on partial or transient updates.
183
+ if "firmware" not in device:
184
+ continue
185
+ new_firmware = device.get("firmware")
186
+ if new_firmware is None:
187
+ continue
188
+ if (
189
+ device_id in self._firmware_list
190
+ and new_firmware != self._firmware_list[device_id]
191
+ ):
192
+ updated = self._update_firmware_in_dr(device_id, new_firmware)
193
+ if updated:
194
+ self._firmware_list[device_id] = new_firmware
195
+
196
+ def _update_firmware_in_dr(self, device_id: str, firmware: str | None) -> bool:
197
+ """Update device sw_version in device_registry."""
198
+ device_reg = dr.async_get(self.hass)
199
+ if (
200
+ device_entry := device_reg.async_get_device_by_identifier(
201
+ (DOMAIN, device_id), self.config_entry.entry_id
202
+ )
203
+ ) is not None:
204
+ device_reg.async_update_device(device_entry.id, sw_version=firmware)
205
+ LOGGER.debug(
206
+ "Firmware in device_registry updated for %s %s %s",
207
+ DOMAIN,
208
+ device_entry.model,
209
+ device_id,
210
+ )
211
+ return True
212
+
213
+ return False # pragma: no cover
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/diagnostics.py RENAMED
@@ -1,26 +1,15 @@
1
  """Diagnostics support for Plugwise."""
2
- from __future__ import annotations
3
 
4
  from typing import Any
5
 
6
- from homeassistant.config_entries import ConfigEntry
7
  from homeassistant.core import HomeAssistant
8
 
9
- from .const import (
10
- COORDINATOR, # pw-beta
11
- DOMAIN,
12
- )
13
- from .coordinator import PlugwiseDataUpdateCoordinator
14
 
15
 
16
  async def async_get_config_entry_diagnostics(
17
- hass: HomeAssistant, entry: ConfigEntry
18
  ) -> dict[str, Any]:
19
  """Return diagnostics for a config entry."""
20
- coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
21
- COORDINATOR
22
- ]
23
- return {
24
- "gateway": coordinator.data.gateway,
25
- "devices": coordinator.data.devices,
26
- }
 
1
  """Diagnostics support for Plugwise."""
 
2
 
3
  from typing import Any
4
 
 
5
  from homeassistant.core import HomeAssistant
6
 
7
+ from .coordinator import PlugwiseConfigEntry
 
 
 
 
8
 
9
 
10
  async def async_get_config_entry_diagnostics(
11
+ hass: HomeAssistant, entry: PlugwiseConfigEntry
12
  ) -> dict[str, Any]:
13
  """Return diagnostics for a config entry."""
14
+ coordinator = entry.runtime_data
15
+ return coordinator.data
 
 
 
 
 
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/entity.py RENAMED
@@ -1,16 +1,19 @@
1
  """Generic Plugwise Entity Class."""
2
- from __future__ import annotations
3
 
4
- from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE, CONF_HOST
 
 
 
 
5
  from homeassistant.helpers.device_registry import (
6
  CONNECTION_NETWORK_MAC,
7
  CONNECTION_ZIGBEE,
 
 
8
  )
9
- from homeassistant.helpers.entity import DeviceInfo
10
  from homeassistant.helpers.update_coordinator import CoordinatorEntity
11
- from plugwise.constants import DeviceData
12
 
13
- from .const import DOMAIN
14
  from .coordinator import PlugwiseDataUpdateCoordinator
15
 
16
 
@@ -28,54 +31,62 @@
28
  super().__init__(coordinator)
29
  self._dev_id = device_id
30
 
31
- configuration_url: str | None = None
32
- if entry := self.coordinator.config_entry:
33
- configuration_url = f"http://{entry.data[CONF_HOST]}"
34
-
35
- data = coordinator.data.devices[device_id]
36
- connections = set()
37
- if mac := data.get("mac_address"):
38
- connections.add((CONNECTION_NETWORK_MAC, mac))
39
- if mac := data.get("zigbee_mac_address"):
40
- connections.add((CONNECTION_ZIGBEE, mac))
 
 
 
 
 
 
 
 
 
 
 
 
41
 
 
42
  self._attr_device_info = DeviceInfo(
43
  configuration_url=configuration_url,
44
  identifiers={(DOMAIN, device_id)},
45
  connections=connections,
46
- manufacturer=data.get("vendor"),
47
- model=data.get("model"),
48
- name=coordinator.data.gateway["smile_name"],
49
- sw_version=data.get("firmware"),
50
- hw_version=data.get("hardware"),
 
51
  )
52
 
53
- if device_id != coordinator.data.gateway["gateway_id"]:
54
- self._attr_device_info.update(
55
- {
56
- ATTR_NAME: data.get("name"),
57
- ATTR_VIA_DEVICE: (
58
- DOMAIN,
59
- str(self.coordinator.data.gateway["gateway_id"]),
60
- ),
61
- }
62
  )
63
 
64
  @property
 
65
  def available(self) -> bool:
66
  """Return if entity is available."""
67
  return (
68
- self._dev_id in self.coordinator.data.devices
69
- and ("available" not in self.device or self.device["available"] is True)
70
  and super().available
71
  )
72
 
73
  @property
74
- def device(self) -> DeviceData:
75
  """Return data for this device."""
76
- return self.coordinator.data.devices[self._dev_id]
77
-
78
- async def async_added_to_hass(self) -> None:
79
- """Subscribe to updates."""
80
- self._handle_coordinator_update()
81
- await super().async_added_to_hass()
 
1
  """Generic Plugwise Entity Class."""
 
2
 
3
+ from typing import override
4
+
5
+ from plugwise import GwEntityData
6
+
7
+ from homeassistant.const import ATTR_NAME, CONF_HOST
8
  from homeassistant.helpers.device_registry import (
9
  CONNECTION_NETWORK_MAC,
10
  CONNECTION_ZIGBEE,
11
+ DeviceInfo,
12
+ async_get_device_id_by_identifier,
13
  )
 
14
  from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
15
 
16
+ from .const import AVAILABLE, DOMAIN
17
  from .coordinator import PlugwiseDataUpdateCoordinator
18
 
19
 
 
31
  super().__init__(coordinator)
32
  self._dev_id = device_id
33
 
34
+ api = coordinator.api
35
+ gateway_id = api.gateway_id
36
+ entry = coordinator.config_entry
37
+
38
+ # Link configuration-URL for the gateway device
39
+ configuration_url = (
40
+ f"http://{entry.data[CONF_HOST]}"
41
+ if device_id == gateway_id and entry
42
+ else None
43
+ )
44
+
45
+ # Build connections set
46
+ connections = {
47
+ (CONNECTION_NETWORK_MAC, mac_address)
48
+ for mac_address in (
49
+ self.device.get("mac_address"),
50
+ self.device.get("wifi_mac_address"),
51
+ )
52
+ if mac_address is not None
53
+ }
54
+ if zigbee_mac := self.device.get("zigbee_mac_address"):
55
+ connections.add((CONNECTION_ZIGBEE, zigbee_mac))
56
 
57
+ # Set base device info
58
  self._attr_device_info = DeviceInfo(
59
  configuration_url=configuration_url,
60
  identifiers={(DOMAIN, device_id)},
61
  connections=connections,
62
+ manufacturer=self.device.get("vendor"),
63
+ model=self.device.get("model"),
64
+ model_id=self.device.get("model_id"),
65
+ name=api.smile.name,
66
+ sw_version=self.device.get("firmware"),
67
+ hw_version=self.device.get("hardware"),
68
  )
69
 
70
+ # Add extra info if not the gateway device
71
+ if device_id != gateway_id:
72
+ self._attr_device_info.name = self.device.get(ATTR_NAME)
73
+ self._attr_device_info.via_device_id = async_get_device_id_by_identifier(
74
+ coordinator.hass,
75
+ (DOMAIN, gateway_id),
76
+ config_entry_id=entry.entry_id,
 
 
77
  )
78
 
79
  @property
80
+ @override
81
  def available(self) -> bool:
82
  """Return if entity is available."""
83
  return (
84
+ self._dev_id in self.coordinator.data
85
+ and (AVAILABLE not in self.device or self.device[AVAILABLE] is True)
86
  and super().available
87
  )
88
 
89
  @property
90
+ def device(self) -> GwEntityData:
91
  """Return data for this device."""
92
+ return self.coordinator.data[self._dev_id]
 
 
 
 
 
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/manifest.json RENAMED
@@ -1,13 +1,15 @@
1
  {
2
  "domain": "plugwise",
3
- "name": "Plugwise Smile/Stretch Beta",
4
- "after_dependencies": ["zeroconf"],
5
  "codeowners": ["@CoMPaTech", "@bouwew"],
6
  "config_flow": true,
7
- "documentation": "https://github.com/plugwise/plugwise-beta",
 
8
  "integration_type": "hub",
9
  "iot_class": "local_polling",
10
  "loggers": ["plugwise"],
11
- "requirements": ["plugwise==0.31.6"],
12
- "version": "0.40.3a6"
 
13
  }
 
1
  {
2
  "domain": "plugwise",
3
+ "name": "Plugwise",
4
+ "categories": ["climate.thermostats_heating", "energy.meters_monitoring"],
5
  "codeowners": ["@CoMPaTech", "@bouwew"],
6
  "config_flow": true,
7
+ "connectivity": ["network"],
8
+ "documentation": "https://www.home-assistant.io/integrations/plugwise",
9
  "integration_type": "hub",
10
  "iot_class": "local_polling",
11
  "loggers": ["plugwise"],
12
+ "quality_scale": "platinum",
13
+ "requirements": ["plugwise==1.16.0"],
14
+ "zeroconf": ["_plugwise._tcp.local."]
15
  }
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/number.py RENAMED
@@ -1,8 +1,9 @@
1
  """Number platform for Plugwise integration."""
2
- from __future__ import annotations
3
 
4
- from collections.abc import Awaitable, Callable
5
  from dataclasses import dataclass
 
 
 
6
 
7
  from homeassistant.components.number import (
8
  NumberDeviceClass,
@@ -10,89 +11,134 @@
10
  NumberEntityDescription,
11
  NumberMode,
12
  )
13
- from homeassistant.config_entries import ConfigEntry
14
- from homeassistant.const import EntityCategory, UnitOfTemperature
15
- from homeassistant.core import HomeAssistant
16
- from homeassistant.helpers.entity_platform import AddEntitiesCallback
17
- from plugwise import ActuatorData, DeviceData, Smile
18
 
19
  from .const import (
20
- COORDINATOR, # pw-beta
21
- DOMAIN,
22
- LOGGER,
 
 
23
  )
24
- from .coordinator import PlugwiseDataUpdateCoordinator
25
  from .entity import PlugwiseEntity
 
26
 
27
-
28
- @dataclass
29
- class PlugwiseNumberMixin:
30
- """Mixin values for Plugwse entities."""
31
-
32
- command: Callable[[Smile, str, float], Awaitable[None]]
33
- native_max_value_fn: Callable[[ActuatorData], float]
34
- native_min_value_fn: Callable[[ActuatorData], float]
35
- native_step_fn: Callable[[ActuatorData], float]
36
- native_value_fn: Callable[[ActuatorData], float]
37
- actuator_fn: Callable[[DeviceData], ActuatorData | None]
38
 
39
 
40
- @dataclass
41
- class PlugwiseNumberEntityDescription(NumberEntityDescription, PlugwiseNumberMixin):
42
  """Class describing Plugwise Number entities."""
43
 
 
 
 
 
44
 
45
  NUMBER_TYPES = (
46
  PlugwiseNumberEntityDescription(
47
- key="maximum_boiler_temperature",
 
48
  translation_key="maximum_boiler_temperature",
49
- command=lambda api, number, value: api.set_number_setpoint(number, value),
50
  device_class=NumberDeviceClass.TEMPERATURE,
51
  entity_category=EntityCategory.CONFIG,
52
  native_unit_of_measurement=UnitOfTemperature.CELSIUS,
53
- native_max_value_fn=lambda data: data["upper_bound"],
54
- native_min_value_fn=lambda data: data["lower_bound"],
55
- native_step_fn=lambda data: data["resolution"],
56
- native_value_fn=lambda data: data["setpoint"],
57
- actuator_fn=lambda data: data.get("maximum_boiler_temperature"),
58
  ),
59
  PlugwiseNumberEntityDescription(
60
- key="max_dhw_temperature",
 
61
  translation_key="max_dhw_temperature",
62
- command=lambda api, number, value: api.set_number_setpoint(number, value),
63
  device_class=NumberDeviceClass.TEMPERATURE,
64
  entity_category=EntityCategory.CONFIG,
65
  native_unit_of_measurement=UnitOfTemperature.CELSIUS,
66
- native_max_value_fn=lambda data: data["upper_bound"],
67
- native_min_value_fn=lambda data: data["lower_bound"],
68
- native_step_fn=lambda data: data["resolution"],
69
- native_value_fn=lambda data: data["setpoint"],
70
- actuator_fn=lambda data: data.get("max_dhw_temperature"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  ),
72
  )
73
 
74
 
 
 
 
 
 
 
 
 
75
  async def async_setup_entry(
76
  hass: HomeAssistant,
77
- config_entry: ConfigEntry,
78
- async_add_entities: AddEntitiesCallback,
79
  ) -> None:
80
  """Set up Plugwise number platform."""
 
 
81
 
82
- coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][
83
- config_entry.entry_id
84
- ][COORDINATOR]
85
-
86
- entities: list[PlugwiseNumberEntity] = []
87
- for device_id, device in coordinator.data.devices.items():
88
- for description in NUMBER_TYPES:
89
- if actuator := description.actuator_fn(device):
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  entities.append(
91
- PlugwiseNumberEntity(actuator, coordinator, device_id, description)
92
  )
93
- LOGGER.debug("Add %s %s number", device["name"], description.name)
94
 
95
- async_add_entities(entities)
 
 
 
96
 
97
 
98
  class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity):
@@ -102,44 +148,47 @@
102
 
103
  def __init__(
104
  self,
105
- actuator: ActuatorData,
106
  coordinator: PlugwiseDataUpdateCoordinator,
107
  device_id: str,
108
  description: PlugwiseNumberEntityDescription,
109
  ) -> None:
110
  """Initiate Plugwise Number."""
111
  super().__init__(coordinator, device_id)
112
- self.actuator = actuator
113
- self.entity_description = description
114
- self._attr_unique_id = f"{device_id}-{description.key}"
115
  self._attr_mode = NumberMode.BOX
 
 
 
 
 
 
 
116
 
117
- @property
118
- def native_max_value(self) -> float:
119
- """Return the setpoint max. value."""
120
- return self.entity_description.native_max_value_fn(self.actuator)
121
-
122
- @property
123
- def native_min_value(self) -> float:
124
- """Return the setpoint min. value."""
125
- return self.entity_description.native_min_value_fn(self.actuator)
126
-
127
- @property
128
- def native_step(self) -> float:
129
- """Return the setpoint step value."""
130
- return max(self.entity_description.native_step_fn(self.actuator), 0.5)
131
 
132
  @property
133
- def native_value(self) -> float:
 
134
  """Return the present setpoint value."""
135
- return self.entity_description.native_value_fn(self.actuator)
 
 
136
 
 
 
137
  async def async_set_native_value(self, value: float) -> None:
138
  """Change to the new setpoint value."""
139
- await self.entity_description.command(
140
- self.coordinator.api, self.entity_description.key, value
141
- )
142
- LOGGER.debug(
143
- "Setting %s to %s was successful", self.entity_description.name, value
144
- )
145
- await self.coordinator.async_request_refresh()
 
 
 
 
 
 
1
  """Number platform for Plugwise integration."""
 
2
 
 
3
  from dataclasses import dataclass
4
+ from typing import override
5
+
6
+ from plugwise.constants import GwEntityData
7
 
8
  from homeassistant.components.number import (
9
  NumberDeviceClass,
 
11
  NumberEntityDescription,
12
  NumberMode,
13
  )
14
+ from homeassistant.const import EntityCategory, Platform, UnitOfTemperature
15
+ from homeassistant.core import HomeAssistant, callback
16
+ from homeassistant.helpers import entity_registry as er
17
+ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 
18
 
19
  from .const import (
20
+ PRESET_COOLING_TEMPERATURE_KEYS,
21
+ PRESET_TEMPERATURE_KEYS,
22
+ NumberDataType,
23
+ NumberType,
24
+ SetpointType,
25
  )
26
+ from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
27
  from .entity import PlugwiseEntity
28
+ from .util import deprecate_entity, plugwise_command
29
 
30
+ PARALLEL_UPDATES = 0
 
 
 
 
 
 
 
 
 
 
31
 
32
 
33
+ @dataclass(frozen=True, kw_only=True)
34
+ class PlugwiseNumberEntityDescription(NumberEntityDescription):
35
  """Class describing Plugwise Number entities."""
36
 
37
+ key: NumberType
38
+ data_key: NumberDataType
39
+ value_keys: tuple[SetpointType, ...] = ("setpoint",)
40
+
41
 
42
  NUMBER_TYPES = (
43
  PlugwiseNumberEntityDescription(
44
+ key="boiler_temperature",
45
+ data_key="boiler_temperature",
46
  translation_key="maximum_boiler_temperature",
 
47
  device_class=NumberDeviceClass.TEMPERATURE,
48
  entity_category=EntityCategory.CONFIG,
49
  native_unit_of_measurement=UnitOfTemperature.CELSIUS,
 
 
 
 
 
50
  ),
51
  PlugwiseNumberEntityDescription(
52
+ key="dhw_temperature",
53
+ data_key="dhw_temperature",
54
  translation_key="max_dhw_temperature",
 
55
  device_class=NumberDeviceClass.TEMPERATURE,
56
  entity_category=EntityCategory.CONFIG,
57
  native_unit_of_measurement=UnitOfTemperature.CELSIUS,
58
+ ),
59
+ PlugwiseNumberEntityDescription(
60
+ key="temperature_offset",
61
+ data_key="temperature_offset",
62
+ translation_key="temperature_offset",
63
+ device_class=NumberDeviceClass.TEMPERATURE,
64
+ entity_category=EntityCategory.CONFIG,
65
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
66
+ ),
67
+ *(
68
+ PlugwiseNumberEntityDescription(
69
+ key=key,
70
+ data_key=key,
71
+ translation_key=key,
72
+ device_class=NumberDeviceClass.TEMPERATURE,
73
+ entity_category=EntityCategory.CONFIG,
74
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
75
+ value_keys=("setpoint", "setpoint_low"),
76
+ )
77
+ for key in PRESET_TEMPERATURE_KEYS
78
+ ),
79
+ *(
80
+ PlugwiseNumberEntityDescription(
81
+ key=key,
82
+ data_key=data_key,
83
+ translation_key=key,
84
+ device_class=NumberDeviceClass.TEMPERATURE,
85
+ entity_category=EntityCategory.CONFIG,
86
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
87
+ value_keys=("setpoint_high",),
88
+ )
89
+ for key, data_key in zip(
90
+ PRESET_COOLING_TEMPERATURE_KEYS, PRESET_TEMPERATURE_KEYS, strict=True
91
+ )
92
  ),
93
  )
94
 
95
 
96
+ def _value_key(
97
+ description: PlugwiseNumberEntityDescription, device: GwEntityData
98
+ ) -> SetpointType | None:
99
+ """Return the first value key of the description present in the device data."""
100
+ ctrl = device.get(description.data_key, {})
101
+ return next((key for key in description.value_keys if key in ctrl), None)
102
+
103
+
104
  async def async_setup_entry(
105
  hass: HomeAssistant,
106
+ entry: PlugwiseConfigEntry,
107
+ async_add_entities: AddConfigEntryEntitiesCallback,
108
  ) -> None:
109
  """Set up Plugwise number platform."""
110
+ coordinator = entry.runtime_data
111
+ entity_registry = er.async_get(hass)
112
 
113
+ @callback
114
+ def _add_entities() -> None:
115
+ """Add Entities."""
116
+ if not coordinator.new_devices:
117
+ return
118
+
119
+ entities = []
120
+ for device_id in coordinator.new_devices:
121
+ for description in NUMBER_TYPES:
122
+ if _value_key(description, coordinator.data[device_id]) is None:
123
+ continue
124
+ if description.key == "dhw_temperature" and not deprecate_entity(
125
+ hass,
126
+ entity_registry,
127
+ async_on_unload=entry.async_on_unload,
128
+ platform_domain=Platform.NUMBER,
129
+ entity_unique_id=f"{device_id}-{description.translation_key}",
130
+ issue_id=f"deprecated_dhw_temperature_{device_id}",
131
+ translation_key="deprecated_dhw_temperature",
132
+ ):
133
+ continue
134
  entities.append(
135
+ PlugwiseNumberEntity(coordinator, device_id, description)
136
  )
 
137
 
138
+ async_add_entities(entities)
139
+
140
+ _add_entities()
141
+ entry.async_on_unload(coordinator.async_add_listener(_add_entities))
142
 
143
 
144
  class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity):
 
148
 
149
  def __init__(
150
  self,
 
151
  coordinator: PlugwiseDataUpdateCoordinator,
152
  device_id: str,
153
  description: PlugwiseNumberEntityDescription,
154
  ) -> None:
155
  """Initiate Plugwise Number."""
156
  super().__init__(coordinator, device_id)
 
 
 
157
  self._attr_mode = NumberMode.BOX
158
+ self._data_key = description.data_key
159
+ ctrl = self.device.get(self._data_key, {})
160
+ self._attr_native_max_value = ctrl.get("upper_bound", 100.0)
161
+ self._attr_native_min_value = ctrl.get("lower_bound", 0.0)
162
+ self._attr_unique_id = f"{device_id}-{description.translation_key}"
163
+ self.device_id = device_id
164
+ self.entity_description = description
165
 
166
+ native_step = ctrl["resolution"]
167
+ if description.key != "temperature_offset":
168
+ native_step = max(native_step, 0.5)
169
+ self._attr_native_step = native_step
 
 
 
 
 
 
 
 
 
 
170
 
171
  @property
172
+ @override
173
+ def native_value(self) -> float | None:
174
  """Return the present setpoint value."""
175
+ ctrl = self.device.get(self._data_key, {})
176
+ value_keys = self.entity_description.value_keys
177
+ return next((ctrl[key] for key in value_keys if key in ctrl), None)
178
 
179
+ @plugwise_command
180
+ @override
181
  async def async_set_native_value(self, value: float) -> None:
182
  """Change to the new setpoint value."""
183
+ if self._data_key in PRESET_TEMPERATURE_KEYS:
184
+ await self.coordinator.api.set_number(
185
+ self.device_id,
186
+ self._data_key,
187
+ value,
188
+ setpoint=_value_key(self.entity_description, self.device)
189
+ or self.entity_description.value_keys[0],
190
+ )
191
+ else:
192
+ await self.coordinator.api.set_number(
193
+ self.device_id, self.entity_description.key, value
194
+ )
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/select.py RENAMED
@@ -1,93 +1,123 @@
1
  """Plugwise Select component for Home Assistant."""
2
- from __future__ import annotations
3
 
4
  from collections.abc import Awaitable, Callable
5
  from dataclasses import dataclass
 
 
 
6
 
7
  from homeassistant.components.select import SelectEntity, SelectEntityDescription
8
- from homeassistant.config_entries import ConfigEntry
9
  from homeassistant.const import STATE_ON, EntityCategory
10
- from homeassistant.core import HomeAssistant
11
- from homeassistant.helpers.entity_platform import AddEntitiesCallback
12
- from plugwise import DeviceData, Smile
13
 
14
  from .const import (
15
- COORDINATOR, # pw-beta
16
- DOMAIN,
17
- LOGGER,
 
 
 
 
 
 
18
  )
19
- from .coordinator import PlugwiseDataUpdateCoordinator
20
  from .entity import PlugwiseEntity
 
21
 
22
  PARALLEL_UPDATES = 0
23
 
24
 
25
- @dataclass
26
- class PlugwiseSelectDescriptionMixin:
27
- """Mixin values for Plugwise Select entities."""
28
-
29
- command: Callable[[Smile, str, str], Awaitable[None]]
30
- value_fn: Callable[[DeviceData], str]
31
- options_fn: Callable[[DeviceData], list[str] | None]
32
-
33
-
34
- @dataclass
35
- class PlugwiseSelectEntityDescription(
36
- SelectEntityDescription, PlugwiseSelectDescriptionMixin
37
- ):
38
- """Class describing Plugwise Number entities."""
39
 
40
 
41
  SELECT_TYPES = (
42
  PlugwiseSelectEntityDescription(
43
- key="select_schedule",
44
- translation_key="thermostat_schedule",
45
- icon="mdi:calendar-clock",
46
- command=lambda api, loc, opt: api.set_schedule_state(loc, opt, STATE_ON),
47
- value_fn=lambda data: data["selected_schedule"],
48
- options_fn=lambda data: data.get("available_schedules"),
 
 
 
 
 
 
 
 
 
 
49
  ),
50
  PlugwiseSelectEntityDescription(
51
- key="select_regulation_mode",
52
- translation_key="regulation_mode",
53
- icon="mdi:hvac",
54
  entity_category=EntityCategory.CONFIG,
55
- command=lambda api, loc, opt: api.set_regulation_mode(opt),
56
- value_fn=lambda data: data["regulation_mode"],
57
- options_fn=lambda data: data.get("regulation_modes"),
 
58
  ),
59
  PlugwiseSelectEntityDescription(
60
- key="select_dhw_mode",
61
- translation_key="dhw_mode",
62
- icon="mdi:shower",
63
  entity_category=EntityCategory.CONFIG,
64
- command=lambda api, loc, opt: api.set_dhw_mode(opt),
65
- value_fn=lambda data: data["dhw_mode"],
66
- options_fn=lambda data: data.get("dhw_modes"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ),
68
  )
69
 
70
 
71
  async def async_setup_entry(
72
  hass: HomeAssistant,
73
- config_entry: ConfigEntry,
74
- async_add_entities: AddEntitiesCallback,
75
  ) -> None:
76
  """Set up the Smile selector from a config entry."""
77
- coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][
78
- config_entry.entry_id
79
- ][COORDINATOR]
80
-
81
- entities: list[PlugwiseSelectEntity] = []
82
- for device_id, device in coordinator.data.devices.items():
83
- for description in SELECT_TYPES:
84
- if (options := description.options_fn(device)) and len(options) > 1:
85
- entities.append(
86
- PlugwiseSelectEntity(coordinator, device_id, description, options)
87
- )
88
- LOGGER.debug("Add %s %s selector", device["name"], description.name)
89
 
90
- async_add_entities(entities)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
 
93
  class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
@@ -100,28 +130,50 @@
100
  coordinator: PlugwiseDataUpdateCoordinator,
101
  device_id: str,
102
  entity_description: PlugwiseSelectEntityDescription,
103
- options: list[str],
104
  ) -> None:
105
  """Initialise the selector."""
106
  super().__init__(coordinator, device_id)
 
 
 
 
107
  self.entity_description = entity_description
108
- self._attr_unique_id = f"{device_id}-{entity_description.key}"
109
- self._attr_options = options
 
 
 
 
 
110
 
111
  @property
112
- def current_option(self) -> str:
 
113
  """Return the selected entity option to represent the entity state."""
114
- # return self.device[self.entity_description.current_option_key] # type: ignore [literal-required]
115
- return self.entity_description.value_fn(self.device)
116
 
 
 
 
 
 
 
 
 
117
  async def async_select_option(self, option: str) -> None:
118
- """Change to the selected entity option."""
119
- await self.entity_description.command(
120
- self.coordinator.api, self.device["location"], option
121
- )
122
- LOGGER.debug(
123
- "Set %s to %s was successful.",
124
- self.entity_description.name,
 
 
 
 
 
 
125
  option,
 
126
  )
127
- await self.coordinator.async_request_refresh()
 
1
  """Plugwise Select component for Home Assistant."""
 
2
 
3
  from collections.abc import Awaitable, Callable
4
  from dataclasses import dataclass
5
+ from typing import override
6
+
7
+ from plugwise import Smile
8
 
9
  from homeassistant.components.select import SelectEntity, SelectEntityDescription
 
10
  from homeassistant.const import STATE_ON, EntityCategory
11
+ from homeassistant.core import HomeAssistant, callback
12
+ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 
13
 
14
  from .const import (
15
+ DHW_MODE,
16
+ LOCATION,
17
+ SELECT_DHW_MODE,
18
+ SELECT_GATEWAY_MODE,
19
+ SELECT_REGULATION_MODE,
20
+ SELECT_SCHEDULE,
21
+ SELECT_ZONE_PROFILE,
22
+ SelectOptionsType,
23
+ SelectType,
24
  )
25
+ from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
26
  from .entity import PlugwiseEntity
27
+ from .util import plugwise_command
28
 
29
  PARALLEL_UPDATES = 0
30
 
31
 
32
+ @dataclass(frozen=True, kw_only=True)
33
+ class PlugwiseSelectEntityDescription(SelectEntityDescription):
34
+ """Class describing Plugwise Select entities."""
35
+
36
+ key: SelectType
37
+ options_key: SelectOptionsType
38
+ set_value_fn: Callable[[Smile, str, str, str, int | str | None], Awaitable[None]]
 
 
 
 
 
 
 
39
 
40
 
41
  SELECT_TYPES = (
42
  PlugwiseSelectEntityDescription(
43
+ key=DHW_MODE,
44
+ translation_key=SELECT_DHW_MODE,
45
+ entity_category=EntityCategory.CONFIG,
46
+ options_key="dhw_modes",
47
+ set_value_fn=lambda api, key, appl_id, option, length: api.set_dhw_mode(
48
+ key, appl_id, option, length
49
+ ),
50
+ ),
51
+ PlugwiseSelectEntityDescription(
52
+ key=SELECT_DHW_MODE,
53
+ translation_key=SELECT_DHW_MODE,
54
+ entity_category=EntityCategory.CONFIG,
55
+ options_key="dhw_modes",
56
+ set_value_fn=lambda api, key, appl_or_loc_id, option, state: api.set_select(
57
+ key, appl_or_loc_id, option, state
58
+ ),
59
  ),
60
  PlugwiseSelectEntityDescription(
61
+ key=SELECT_GATEWAY_MODE,
62
+ translation_key=SELECT_GATEWAY_MODE,
 
63
  entity_category=EntityCategory.CONFIG,
64
+ options_key="gateway_modes",
65
+ set_value_fn=lambda api, key, appl_or_loc_id, option, state: api.set_select(
66
+ key, appl_or_loc_id, option, state
67
+ ),
68
  ),
69
  PlugwiseSelectEntityDescription(
70
+ key=SELECT_REGULATION_MODE,
71
+ translation_key=SELECT_REGULATION_MODE,
 
72
  entity_category=EntityCategory.CONFIG,
73
+ options_key="regulation_modes",
74
+ set_value_fn=lambda api, key, appl_or_loc_id, option, state: api.set_select(
75
+ key, appl_or_loc_id, option, state
76
+ ),
77
+ ),
78
+ PlugwiseSelectEntityDescription(
79
+ key=SELECT_SCHEDULE,
80
+ translation_key=SELECT_SCHEDULE,
81
+ options_key="available_schedules",
82
+ set_value_fn=lambda api, key, appl_or_loc_id, option, state: api.set_select(
83
+ key, appl_or_loc_id, option, state
84
+ ),
85
+ ),
86
+ PlugwiseSelectEntityDescription(
87
+ key=SELECT_ZONE_PROFILE,
88
+ translation_key=SELECT_ZONE_PROFILE,
89
+ entity_category=EntityCategory.CONFIG,
90
+ options_key="zone_profiles",
91
+ set_value_fn=lambda api, key, appl_or_loc_id, option, state: api.set_select(
92
+ key, appl_or_loc_id, option, state
93
+ ),
94
  ),
95
  )
96
 
97
 
98
  async def async_setup_entry(
99
  hass: HomeAssistant,
100
+ entry: PlugwiseConfigEntry,
101
+ async_add_entities: AddConfigEntryEntitiesCallback,
102
  ) -> None:
103
  """Set up the Smile selector from a config entry."""
104
+ coordinator = entry.runtime_data
 
 
 
 
 
 
 
 
 
 
 
105
 
106
+ @callback
107
+ def _add_entities() -> None:
108
+ """Add entities."""
109
+ if not coordinator.new_devices:
110
+ return
111
+
112
+ async_add_entities(
113
+ PlugwiseSelectEntity(coordinator, device_id, description)
114
+ for device_id in coordinator.new_devices
115
+ for description in SELECT_TYPES
116
+ if coordinator.data[device_id].get(description.key)
117
+ )
118
+
119
+ _add_entities()
120
+ entry.async_on_unload(coordinator.async_add_listener(_add_entities))
121
 
122
 
123
  class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
 
130
  coordinator: PlugwiseDataUpdateCoordinator,
131
  device_id: str,
132
  entity_description: PlugwiseSelectEntityDescription,
 
133
  ) -> None:
134
  """Initialise the selector."""
135
  super().__init__(coordinator, device_id)
136
+ suffix = entity_description.key
137
+ if entity_description.key == DHW_MODE:
138
+ suffix = SELECT_DHW_MODE
139
+ self._attr_unique_id = f"{device_id}-{suffix}"
140
  self.entity_description = entity_description
141
+
142
+ self._device_or_location = device_id
143
+ if (
144
+ self.entity_description.key in (SELECT_SCHEDULE, SELECT_ZONE_PROFILE)
145
+ and (location := self.device.get(LOCATION)) is not None
146
+ ):
147
+ self._device_or_location = location
148
 
149
  @property
150
+ @override
151
+ def current_option(self) -> str | None:
152
  """Return the selected entity option to represent the entity state."""
153
+ return self.device[self.entity_description.key]
 
154
 
155
+ @property
156
+ @override
157
+ def options(self) -> list[str]:
158
+ """Return the available select-options."""
159
+ return self.device[self.entity_description.options_key]
160
+
161
+ @plugwise_command
162
+ @override
163
  async def async_select_option(self, option: str) -> None:
164
+ """Change to the selected entity option.
165
+
166
+ The appliance ID (= device_id) is required for the dhw_mode select.
167
+ The location ID is required for the thermostat schedule and zone_profile selects.
168
+ STATE_ON is required for the thermostat schedule select.
169
+ """
170
+ options_count_or_state: int | str | None = len(self.options)
171
+ if self.entity_description.key != DHW_MODE:
172
+ options_count_or_state = STATE_ON
173
+ await self.entity_description.set_value_fn(
174
+ self.coordinator.api,
175
+ self.entity_description.key,
176
+ self._device_or_location,
177
  option,
178
+ options_count_or_state,
179
  )
 
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/sensor.py RENAMED
@@ -1,48 +1,427 @@
1
  """Plugwise Sensor component for Home Assistant."""
2
- from __future__ import annotations
3
 
4
- from homeassistant.components.sensor import SensorEntity
5
- from homeassistant.config_entries import ConfigEntry
6
- from homeassistant.core import HomeAssistant
7
- from homeassistant.helpers.entity_platform import AddEntitiesCallback
8
-
9
- from .const import (
10
- COORDINATOR, # pw-beta
11
- DOMAIN,
12
- LOGGER,
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  )
14
- from .coordinator import PlugwiseDataUpdateCoordinator
 
 
 
15
  from .entity import PlugwiseEntity
16
- from .models import PW_SENSOR_TYPES, PlugwiseSensorEntityDescription
17
 
 
18
  PARALLEL_UPDATES = 0
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  async def async_setup_entry(
22
  hass: HomeAssistant,
23
- config_entry: ConfigEntry,
24
- async_add_entities: AddEntitiesCallback,
25
  ) -> None:
26
  """Set up the Smile sensors from a config entry."""
27
- coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
28
 
29
- entities: list[PlugwiseSensorEntity] = []
30
- for device_id, device in coordinator.data.devices.items():
31
- if not (sensors := device.get("sensors")):
32
- continue
33
- for description in PW_SENSOR_TYPES:
34
- if description.key not in sensors:
35
- continue
36
- entities.append(
37
- PlugwiseSensorEntity(
38
- coordinator,
39
- device_id,
40
- description,
41
- )
42
- )
43
- LOGGER.debug("Add %s sensor", description.key)
44
 
45
- async_add_entities(entities)
 
46
 
47
 
48
  class PlugwiseSensorEntity(PlugwiseEntity, SensorEntity):
@@ -58,10 +437,11 @@
58
  ) -> None:
59
  """Initialise the sensor."""
60
  super().__init__(coordinator, device_id)
61
- self.entity_description = description
62
  self._attr_unique_id = f"{device_id}-{description.key}"
 
63
 
64
  @property
 
65
  def native_value(self) -> int | float:
66
  """Return the value reported by the sensor."""
67
- return self.entity_description.value_fn(self.device["sensors"])
 
1
  """Plugwise Sensor component for Home Assistant."""
 
2
 
3
+ from dataclasses import dataclass
4
+ from typing import override
5
+
6
+ from plugwise.constants import SensorType
7
+
8
+ from homeassistant.components.sensor import (
9
+ SensorDeviceClass,
10
+ SensorEntity,
11
+ SensorEntityDescription,
12
+ SensorStateClass,
13
+ )
14
+ from homeassistant.const import (
15
+ LIGHT_LUX,
16
+ PERCENTAGE,
17
+ EntityCategory,
18
+ UnitOfElectricPotential,
19
+ UnitOfEnergy,
20
+ UnitOfPower,
21
+ UnitOfPressure,
22
+ UnitOfTemperature,
23
+ UnitOfVolume,
24
+ UnitOfVolumeFlowRate,
25
  )
26
+ from homeassistant.core import HomeAssistant, callback
27
+ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
28
+
29
+ from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
30
  from .entity import PlugwiseEntity
 
31
 
32
+ # Coordinator is used to centralize the data updates
33
  PARALLEL_UPDATES = 0
34
 
35
 
36
+ @dataclass(frozen=True)
37
+ class PlugwiseSensorEntityDescription(SensorEntityDescription):
38
+ """Describes Plugwise sensor entity."""
39
+
40
+ key: SensorType
41
+
42
+
43
+ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
44
+ PlugwiseSensorEntityDescription(
45
+ key="setpoint",
46
+ translation_key="setpoint",
47
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
48
+ device_class=SensorDeviceClass.TEMPERATURE,
49
+ state_class=SensorStateClass.MEASUREMENT,
50
+ ),
51
+ PlugwiseSensorEntityDescription(
52
+ key="setpoint_high",
53
+ translation_key="cooling_setpoint",
54
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
55
+ device_class=SensorDeviceClass.TEMPERATURE,
56
+ state_class=SensorStateClass.MEASUREMENT,
57
+ ),
58
+ PlugwiseSensorEntityDescription(
59
+ key="setpoint_low",
60
+ translation_key="heating_setpoint",
61
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
62
+ device_class=SensorDeviceClass.TEMPERATURE,
63
+ state_class=SensorStateClass.MEASUREMENT,
64
+ ),
65
+ PlugwiseSensorEntityDescription(
66
+ key="temperature",
67
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
68
+ device_class=SensorDeviceClass.TEMPERATURE,
69
+ state_class=SensorStateClass.MEASUREMENT,
70
+ ),
71
+ PlugwiseSensorEntityDescription(
72
+ key="intended_boiler_temperature",
73
+ translation_key="intended_boiler_temperature",
74
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
75
+ device_class=SensorDeviceClass.TEMPERATURE,
76
+ entity_category=EntityCategory.DIAGNOSTIC,
77
+ state_class=SensorStateClass.MEASUREMENT,
78
+ ),
79
+ PlugwiseSensorEntityDescription(
80
+ key="temperature_difference",
81
+ translation_key="temperature_difference",
82
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
83
+ device_class=SensorDeviceClass.TEMPERATURE,
84
+ entity_category=EntityCategory.DIAGNOSTIC,
85
+ state_class=SensorStateClass.MEASUREMENT,
86
+ ),
87
+ PlugwiseSensorEntityDescription(
88
+ key="outdoor_temperature",
89
+ translation_key="outdoor_temperature",
90
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
91
+ device_class=SensorDeviceClass.TEMPERATURE,
92
+ entity_category=EntityCategory.DIAGNOSTIC,
93
+ state_class=SensorStateClass.MEASUREMENT,
94
+ ),
95
+ PlugwiseSensorEntityDescription(
96
+ key="outdoor_air_temperature",
97
+ translation_key="outdoor_air_temperature",
98
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
99
+ device_class=SensorDeviceClass.TEMPERATURE,
100
+ entity_category=EntityCategory.DIAGNOSTIC,
101
+ state_class=SensorStateClass.MEASUREMENT,
102
+ ),
103
+ PlugwiseSensorEntityDescription(
104
+ key="water_temperature",
105
+ translation_key="water_temperature",
106
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
107
+ device_class=SensorDeviceClass.TEMPERATURE,
108
+ entity_category=EntityCategory.DIAGNOSTIC,
109
+ state_class=SensorStateClass.MEASUREMENT,
110
+ ),
111
+ PlugwiseSensorEntityDescription(
112
+ key="return_temperature",
113
+ translation_key="return_temperature",
114
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
115
+ device_class=SensorDeviceClass.TEMPERATURE,
116
+ entity_category=EntityCategory.DIAGNOSTIC,
117
+ state_class=SensorStateClass.MEASUREMENT,
118
+ ),
119
+ PlugwiseSensorEntityDescription(
120
+ key="electricity_consumed",
121
+ translation_key="electricity_consumed",
122
+ native_unit_of_measurement=UnitOfPower.WATT,
123
+ device_class=SensorDeviceClass.POWER,
124
+ state_class=SensorStateClass.MEASUREMENT,
125
+ ),
126
+ PlugwiseSensorEntityDescription(
127
+ key="electricity_produced",
128
+ translation_key="electricity_produced",
129
+ native_unit_of_measurement=UnitOfPower.WATT,
130
+ device_class=SensorDeviceClass.POWER,
131
+ state_class=SensorStateClass.MEASUREMENT,
132
+ entity_registry_enabled_default=False,
133
+ ),
134
+ PlugwiseSensorEntityDescription(
135
+ key="electricity_consumed_interval",
136
+ translation_key="electricity_consumed_interval",
137
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
138
+ device_class=SensorDeviceClass.ENERGY,
139
+ state_class=SensorStateClass.TOTAL,
140
+ ),
141
+ PlugwiseSensorEntityDescription(
142
+ key="electricity_consumed_peak_interval",
143
+ translation_key="electricity_consumed_peak_interval",
144
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
145
+ device_class=SensorDeviceClass.ENERGY,
146
+ state_class=SensorStateClass.TOTAL,
147
+ ),
148
+ PlugwiseSensorEntityDescription(
149
+ key="electricity_consumed_off_peak_interval",
150
+ translation_key="electricity_consumed_off_peak_interval",
151
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
152
+ device_class=SensorDeviceClass.ENERGY,
153
+ state_class=SensorStateClass.TOTAL,
154
+ ),
155
+ PlugwiseSensorEntityDescription(
156
+ key="electricity_produced_interval",
157
+ translation_key="electricity_produced_interval",
158
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
159
+ device_class=SensorDeviceClass.ENERGY,
160
+ state_class=SensorStateClass.TOTAL,
161
+ entity_registry_enabled_default=False,
162
+ ),
163
+ PlugwiseSensorEntityDescription(
164
+ key="electricity_produced_peak_interval",
165
+ translation_key="electricity_produced_peak_interval",
166
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
167
+ device_class=SensorDeviceClass.ENERGY,
168
+ state_class=SensorStateClass.TOTAL,
169
+ ),
170
+ PlugwiseSensorEntityDescription(
171
+ key="electricity_produced_off_peak_interval",
172
+ translation_key="electricity_produced_off_peak_interval",
173
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
174
+ device_class=SensorDeviceClass.ENERGY,
175
+ state_class=SensorStateClass.TOTAL,
176
+ ),
177
+ PlugwiseSensorEntityDescription(
178
+ key="electricity_consumed_point",
179
+ translation_key="electricity_consumed_point",
180
+ device_class=SensorDeviceClass.POWER,
181
+ native_unit_of_measurement=UnitOfPower.WATT,
182
+ state_class=SensorStateClass.MEASUREMENT,
183
+ ),
184
+ PlugwiseSensorEntityDescription(
185
+ key="electricity_consumed_off_peak_point",
186
+ translation_key="electricity_consumed_off_peak_point",
187
+ native_unit_of_measurement=UnitOfPower.WATT,
188
+ device_class=SensorDeviceClass.POWER,
189
+ state_class=SensorStateClass.MEASUREMENT,
190
+ ),
191
+ PlugwiseSensorEntityDescription(
192
+ key="electricity_consumed_peak_point",
193
+ translation_key="electricity_consumed_peak_point",
194
+ native_unit_of_measurement=UnitOfPower.WATT,
195
+ device_class=SensorDeviceClass.POWER,
196
+ state_class=SensorStateClass.MEASUREMENT,
197
+ ),
198
+ PlugwiseSensorEntityDescription(
199
+ key="electricity_consumed_off_peak_cumulative",
200
+ translation_key="electricity_consumed_off_peak_cumulative",
201
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
202
+ device_class=SensorDeviceClass.ENERGY,
203
+ state_class=SensorStateClass.TOTAL_INCREASING,
204
+ ),
205
+ PlugwiseSensorEntityDescription(
206
+ key="electricity_consumed_peak_cumulative",
207
+ translation_key="electricity_consumed_peak_cumulative",
208
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
209
+ device_class=SensorDeviceClass.ENERGY,
210
+ state_class=SensorStateClass.TOTAL_INCREASING,
211
+ ),
212
+ PlugwiseSensorEntityDescription(
213
+ key="electricity_produced_point",
214
+ translation_key="electricity_produced_point",
215
+ device_class=SensorDeviceClass.POWER,
216
+ native_unit_of_measurement=UnitOfPower.WATT,
217
+ state_class=SensorStateClass.MEASUREMENT,
218
+ ),
219
+ PlugwiseSensorEntityDescription(
220
+ key="electricity_produced_off_peak_point",
221
+ translation_key="electricity_produced_off_peak_point",
222
+ native_unit_of_measurement=UnitOfPower.WATT,
223
+ device_class=SensorDeviceClass.POWER,
224
+ state_class=SensorStateClass.MEASUREMENT,
225
+ ),
226
+ PlugwiseSensorEntityDescription(
227
+ key="electricity_produced_peak_point",
228
+ translation_key="electricity_produced_peak_point",
229
+ native_unit_of_measurement=UnitOfPower.WATT,
230
+ device_class=SensorDeviceClass.POWER,
231
+ state_class=SensorStateClass.MEASUREMENT,
232
+ ),
233
+ PlugwiseSensorEntityDescription(
234
+ key="electricity_produced_off_peak_cumulative",
235
+ translation_key="electricity_produced_off_peak_cumulative",
236
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
237
+ device_class=SensorDeviceClass.ENERGY,
238
+ state_class=SensorStateClass.TOTAL_INCREASING,
239
+ ),
240
+ PlugwiseSensorEntityDescription(
241
+ key="electricity_produced_peak_cumulative",
242
+ translation_key="electricity_produced_peak_cumulative",
243
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
244
+ device_class=SensorDeviceClass.ENERGY,
245
+ state_class=SensorStateClass.TOTAL_INCREASING,
246
+ ),
247
+ PlugwiseSensorEntityDescription(
248
+ key="electricity_phase_one_consumed",
249
+ translation_key="electricity_phase_one_consumed",
250
+ device_class=SensorDeviceClass.POWER,
251
+ native_unit_of_measurement=UnitOfPower.WATT,
252
+ state_class=SensorStateClass.MEASUREMENT,
253
+ ),
254
+ PlugwiseSensorEntityDescription(
255
+ key="electricity_phase_two_consumed",
256
+ translation_key="electricity_phase_two_consumed",
257
+ device_class=SensorDeviceClass.POWER,
258
+ native_unit_of_measurement=UnitOfPower.WATT,
259
+ state_class=SensorStateClass.MEASUREMENT,
260
+ ),
261
+ PlugwiseSensorEntityDescription(
262
+ key="electricity_phase_three_consumed",
263
+ translation_key="electricity_phase_three_consumed",
264
+ device_class=SensorDeviceClass.POWER,
265
+ native_unit_of_measurement=UnitOfPower.WATT,
266
+ state_class=SensorStateClass.MEASUREMENT,
267
+ ),
268
+ PlugwiseSensorEntityDescription(
269
+ key="electricity_phase_one_produced",
270
+ translation_key="electricity_phase_one_produced",
271
+ device_class=SensorDeviceClass.POWER,
272
+ native_unit_of_measurement=UnitOfPower.WATT,
273
+ state_class=SensorStateClass.MEASUREMENT,
274
+ ),
275
+ PlugwiseSensorEntityDescription(
276
+ key="electricity_phase_two_produced",
277
+ translation_key="electricity_phase_two_produced",
278
+ device_class=SensorDeviceClass.POWER,
279
+ native_unit_of_measurement=UnitOfPower.WATT,
280
+ state_class=SensorStateClass.MEASUREMENT,
281
+ ),
282
+ PlugwiseSensorEntityDescription(
283
+ key="electricity_phase_three_produced",
284
+ translation_key="electricity_phase_three_produced",
285
+ device_class=SensorDeviceClass.POWER,
286
+ native_unit_of_measurement=UnitOfPower.WATT,
287
+ state_class=SensorStateClass.MEASUREMENT,
288
+ ),
289
+ PlugwiseSensorEntityDescription(
290
+ key="voltage_phase_one",
291
+ translation_key="voltage_phase_one",
292
+ device_class=SensorDeviceClass.VOLTAGE,
293
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
294
+ state_class=SensorStateClass.MEASUREMENT,
295
+ entity_registry_enabled_default=False,
296
+ ),
297
+ PlugwiseSensorEntityDescription(
298
+ key="voltage_phase_two",
299
+ translation_key="voltage_phase_two",
300
+ device_class=SensorDeviceClass.VOLTAGE,
301
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
302
+ state_class=SensorStateClass.MEASUREMENT,
303
+ entity_registry_enabled_default=False,
304
+ ),
305
+ PlugwiseSensorEntityDescription(
306
+ key="voltage_phase_three",
307
+ translation_key="voltage_phase_three",
308
+ device_class=SensorDeviceClass.VOLTAGE,
309
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
310
+ state_class=SensorStateClass.MEASUREMENT,
311
+ entity_registry_enabled_default=False,
312
+ ),
313
+ PlugwiseSensorEntityDescription(
314
+ key="gas_consumed_interval",
315
+ translation_key="gas_consumed_interval",
316
+ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
317
+ state_class=SensorStateClass.MEASUREMENT,
318
+ ),
319
+ PlugwiseSensorEntityDescription(
320
+ key="gas_consumed_cumulative",
321
+ translation_key="gas_consumed_cumulative",
322
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
323
+ device_class=SensorDeviceClass.GAS,
324
+ state_class=SensorStateClass.TOTAL,
325
+ ),
326
+ PlugwiseSensorEntityDescription(
327
+ key="net_electricity_point",
328
+ translation_key="net_electricity_point",
329
+ native_unit_of_measurement=UnitOfPower.WATT,
330
+ device_class=SensorDeviceClass.POWER,
331
+ state_class=SensorStateClass.MEASUREMENT,
332
+ ),
333
+ PlugwiseSensorEntityDescription(
334
+ key="net_electricity_cumulative",
335
+ translation_key="net_electricity_cumulative",
336
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
337
+ device_class=SensorDeviceClass.ENERGY,
338
+ state_class=SensorStateClass.TOTAL,
339
+ ),
340
+ PlugwiseSensorEntityDescription(
341
+ key="battery",
342
+ native_unit_of_measurement=PERCENTAGE,
343
+ device_class=SensorDeviceClass.BATTERY,
344
+ entity_category=EntityCategory.DIAGNOSTIC,
345
+ state_class=SensorStateClass.MEASUREMENT,
346
+ ),
347
+ PlugwiseSensorEntityDescription(
348
+ key="illuminance",
349
+ native_unit_of_measurement=LIGHT_LUX,
350
+ device_class=SensorDeviceClass.ILLUMINANCE,
351
+ entity_category=EntityCategory.DIAGNOSTIC,
352
+ state_class=SensorStateClass.MEASUREMENT,
353
+ ),
354
+ PlugwiseSensorEntityDescription(
355
+ key="modulation_level",
356
+ translation_key="modulation_level",
357
+ native_unit_of_measurement=PERCENTAGE,
358
+ entity_category=EntityCategory.DIAGNOSTIC,
359
+ state_class=SensorStateClass.MEASUREMENT,
360
+ ),
361
+ PlugwiseSensorEntityDescription(
362
+ key="valve_position",
363
+ translation_key="valve_position",
364
+ native_unit_of_measurement=PERCENTAGE,
365
+ entity_category=EntityCategory.DIAGNOSTIC,
366
+ state_class=SensorStateClass.MEASUREMENT,
367
+ ),
368
+ PlugwiseSensorEntityDescription(
369
+ key="water_pressure",
370
+ translation_key="water_pressure",
371
+ native_unit_of_measurement=UnitOfPressure.BAR,
372
+ device_class=SensorDeviceClass.PRESSURE,
373
+ entity_category=EntityCategory.DIAGNOSTIC,
374
+ state_class=SensorStateClass.MEASUREMENT,
375
+ ),
376
+ PlugwiseSensorEntityDescription(
377
+ key="humidity",
378
+ native_unit_of_measurement=PERCENTAGE,
379
+ device_class=SensorDeviceClass.HUMIDITY,
380
+ state_class=SensorStateClass.MEASUREMENT,
381
+ ),
382
+ PlugwiseSensorEntityDescription(
383
+ key="dhw_temperature",
384
+ translation_key="dhw_temperature",
385
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
386
+ device_class=SensorDeviceClass.TEMPERATURE,
387
+ entity_category=EntityCategory.DIAGNOSTIC,
388
+ state_class=SensorStateClass.MEASUREMENT,
389
+ ),
390
+ PlugwiseSensorEntityDescription(
391
+ key="domestic_hot_water_setpoint",
392
+ translation_key="domestic_hot_water_setpoint",
393
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
394
+ device_class=SensorDeviceClass.TEMPERATURE,
395
+ entity_category=EntityCategory.DIAGNOSTIC,
396
+ state_class=SensorStateClass.MEASUREMENT,
397
+ ),
398
+ )
399
+
400
+
401
  async def async_setup_entry(
402
  hass: HomeAssistant,
403
+ entry: PlugwiseConfigEntry,
404
+ async_add_entities: AddConfigEntryEntitiesCallback,
405
  ) -> None:
406
  """Set up the Smile sensors from a config entry."""
407
+ coordinator = entry.runtime_data
408
 
409
+ @callback
410
+ def _add_entities() -> None:
411
+ """Add Entities."""
412
+ if not coordinator.new_devices:
413
+ return
414
+
415
+ async_add_entities(
416
+ PlugwiseSensorEntity(coordinator, device_id, description)
417
+ for device_id in coordinator.new_devices
418
+ if (sensors := coordinator.data[device_id].get("sensors"))
419
+ for description in SENSORS
420
+ if description.key in sensors
421
+ )
 
 
422
 
423
+ _add_entities()
424
+ entry.async_on_unload(coordinator.async_add_listener(_add_entities))
425
 
426
 
427
  class PlugwiseSensorEntity(PlugwiseEntity, SensorEntity):
 
437
  ) -> None:
438
  """Initialise the sensor."""
439
  super().__init__(coordinator, device_id)
 
440
  self._attr_unique_id = f"{device_id}-{description.key}"
441
+ self.entity_description = description
442
 
443
  @property
444
+ @override
445
  def native_value(self) -> int | float:
446
  """Return the value reported by the sensor."""
447
+ return self.device["sensors"][self.entity_description.key]
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/strings.json RENAMED
@@ -1,48 +1,46 @@
1
  {
2
- "options": {
3
- "step": {
4
- "none": {
5
- "title": "No Options available",
6
- "description": "This Integration does not provide any Options"
7
- },
8
- "init": {
9
- "description": "Adjust Smile/Stretch Options",
10
- "data": {
11
- "cooling_on": "Anna: cooling-mode is on",
12
- "scan_interval": "Scan Interval (seconds) *) beta-only option",
13
- "homekit_emulation": "Homekit emulation (i.e. on hvac_off => Away) *) beta-only option",
14
- "refresh_interval": "Frontend refresh-time (1.5 - 5 seconds) *) beta-only option"
15
- }
16
- }
17
- }
18
- },
19
  "config": {
20
- "step": {
21
- "user": {
22
- "title": "Connect to the Plugwise Adam/Smile/Stretch",
23
- "description": "Please enter:",
24
- "data": {
25
- "password": "ID",
26
- "username": "Username",
27
- "host": "IP-address",
28
- "port": "Port number"
29
- }
30
- }
31
  },
32
  "error": {
33
- "cannot_connect": "Failed to connect",
34
- "invalid_auth": "Authentication failed",
35
  "invalid_setup": "Add your Adam instead of your Anna, see the documentation",
36
- "network_down": "Plugwise Zigbee network is down",
37
- "network_timeout": "Network communication timeout",
38
  "response_error": "Invalid XML data, or error indication received",
39
- "stick_init": "Initialization of Plugwise USB-stick failed",
40
- "unknown": "Unknown error!",
41
  "unsupported": "Device with unsupported firmware"
42
  },
43
- "abort": {
44
- "already_configured": "This device is already configured",
45
- "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  }
47
  },
48
  "entity": {
@@ -53,6 +51,9 @@
53
  "cooling_enabled": {
54
  "name": "Cooling enabled"
55
  },
 
 
 
56
  "dhw_state": {
57
  "name": "DHW state"
58
  },
@@ -60,230 +61,331 @@
60
  "name": "Flame state"
61
  },
62
  "heating_state": {
63
- "name": "Heating"
64
- },
65
- "cooling_state": {
66
- "name": "Cooling"
67
- },
68
- "slave_boiler_state": {
69
- "name": "Secondary boiler state"
70
  },
71
  "plugwise_notification": {
72
  "name": "Plugwise notification"
 
 
 
 
 
 
 
 
73
  }
74
  },
75
  "climate": {
76
  "plugwise": {
77
  "state_attributes": {
 
 
 
78
  "preset_mode": {
79
  "state": {
80
  "asleep": "Night",
81
- "away": "Away",
82
- "home": "Home",
83
  "no_frost": "Anti-frost",
84
  "vacation": "Vacation"
85
  }
 
 
 
86
  }
87
  }
88
  }
89
  },
90
  "number": {
91
- "maximum_boiler_temperature": {
92
- "name": "Maximum boiler temperature setpoint"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  },
94
  "max_dhw_temperature": {
95
  "name": "Domestic hot water setpoint"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  }
97
  },
98
  "select": {
99
- "dhw_mode": {
100
  "name": "DHW mode",
101
  "state": {
102
- "auto": "Auto",
103
  "boost": "Boost",
104
  "comfort": "Comfort",
105
- "off": "Off"
 
106
  }
107
  },
108
- "regulation_mode": {
 
 
 
 
 
 
 
 
109
  "name": "Regulation mode",
110
  "state": {
111
  "bleeding_cold": "Bleeding cold",
112
  "bleeding_hot": "Bleeding hot",
113
- "cooling": "Cooling",
114
- "heating": "Heating",
115
- "off": "Off"
 
 
 
 
 
 
116
  }
117
  },
118
- "thermostat_schedule": {
119
- "name": "Thermostat schedule"
 
 
 
 
 
120
  }
121
  },
122
  "sensor": {
123
- "setpoint": {
124
- "name": "Setpoint"
125
- },
126
  "cooling_setpoint": {
127
  "name": "Cooling setpoint"
128
  },
129
- "heating_setpoint": {
130
- "name": " Heating setpoint"
131
- },
132
- "temperature": {
133
- "name": "Temperature"
134
- },
135
- "intended_boiler_temperature": {
136
- "name": "Intended boiler temperature"
137
- },
138
- "temperature_difference": {
139
- "name": "Temperature difference"
140
- },
141
- "outdoor_temperature": {
142
- "name": "Outdoor temperature"
143
- },
144
- "outdoor_air_temperature": {
145
- "name": "Outdoor air temperature"
146
- },
147
- "water_temperature": {
148
- "name": "Water temperature"
149
  },
150
- "return_temperature": {
151
- "name": "Return temperature"
152
  },
153
  "electricity_consumed": {
154
  "name": "Electricity consumed"
155
  },
156
- "electricity_produced": {
157
- "name": "Electricity produced"
158
- },
159
- "electricity_consumed_point": {
160
- "name": "Electricity consumed point"
161
- },
162
- "electricity_produced_point": {
163
- "name": "Electricity produced point"
164
- },
165
  "electricity_consumed_interval": {
166
  "name": "Electricity consumed interval"
167
  },
168
- "electricity_consumed_peak_interval": {
169
- "name": "Electricity consumed peak interval"
170
  },
171
  "electricity_consumed_off_peak_interval": {
172
- "name": "Electricity consumed off peak interval"
173
- },
174
- "electricity_produced_interval": {
175
- "name": "Electricity produced interval"
176
- },
177
- "electricity_produced_peak_interval": {
178
- "name": "Electricity produced peak interval"
179
- },
180
- "electricity_produced_off_peak_interval": {
181
- "name": "Electricity produced off peak interval"
182
  },
183
  "electricity_consumed_off_peak_point": {
184
- "name": "Electricity consumed off peak point"
185
- },
186
- "electricity_consumed_peak_point": {
187
- "name": "Electricity consumed peak point"
188
- },
189
- "electricity_consumed_off_peak_cumulative": {
190
- "name": "Electricity consumed off peak cumulative"
191
  },
192
  "electricity_consumed_peak_cumulative": {
193
  "name": "Electricity consumed peak cumulative"
194
  },
195
- "electricity_produced_off_peak_point": {
196
- "name": "Electricity produced off peak point"
197
- },
198
- "electricity_produced_peak_point": {
199
- "name": "Electricity produced peak point"
200
  },
201
- "electricity_produced_off_peak_cumulative": {
202
- "name": "Electricity produced off peak_cumulative"
203
  },
204
- "electricity_produced_peak_cumulative": {
205
- "name": "Electricity produced peak cumulative"
206
  },
207
  "electricity_phase_one_consumed": {
208
  "name": "Electricity phase one consumed"
209
  },
210
- "electricity_phase_two_consumed": {
211
- "name": "Electricity phase two consumed"
212
  },
213
  "electricity_phase_three_consumed": {
214
  "name": "Electricity phase three consumed"
215
  },
216
- "electricity_phase_one_produced": {
217
- "name": "Electricity phase one produced"
 
 
 
218
  },
219
  "electricity_phase_two_produced": {
220
  "name": "Electricity phase two produced"
221
  },
222
- "electricity_phase_three_produced": {
223
- "name": "Electricity phase three produced"
224
  },
225
- "voltage_phase_one": {
226
- "name": "Voltage phase one"
227
  },
228
- "voltage_phase_two": {
229
- "name": "Voltage phase two"
230
  },
231
- "voltage_phase_three": {
232
- "name": "Voltage phase three"
233
  },
234
- "gas_consumed_interval": {
235
- "name": "Gas consumed interval"
 
 
 
 
 
 
 
 
 
 
 
 
236
  },
237
  "gas_consumed_cumulative": {
238
  "name": "Gas consumed cumulative"
239
  },
240
- "net_electricity_point": {
241
- "name": "Net electricity point"
242
  },
243
- "net_electricity_cumulative": {
244
- "name": "Net electricity cumulative"
245
  },
246
- "battery": {
247
- "name": "Battery"
248
  },
249
- "illuminance": {
250
- "name": "Illuminance"
251
  },
252
  "modulation_level": {
253
  "name": "Modulation level"
254
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  "valve_position": {
256
  "name": "Valve position"
257
  },
258
- "water_pressure": {
259
- "name": "Water pressure"
260
  },
261
- "relative_humidity": {
262
- "name": "Relative humidity"
263
  },
264
- "dhw_temperature": {
265
- "name": "DHW temperature"
266
  },
267
- "domestic_hot_water_setpoint": {
268
- "name": "DHW setpoint"
269
  },
270
- "maximum_boiler_temperature": {
271
- "name": "Maximum boiler temperature setpoint"
272
  }
273
  },
274
  "switch": {
275
  "cooling_ena_switch": {
276
- "name": "Cooling"
277
  },
278
  "dhw_cm_switch": {
279
  "name": "DHW comfort mode"
280
  },
281
  "lock": {
282
- "name": "Lock"
283
  },
284
  "relay": {
285
  "name": "Relay"
286
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  }
288
  }
289
  }
 
1
  {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  "config": {
3
+ "abort": {
4
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
5
+ "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna",
6
+ "not_the_same_smile": "The configured Smile ID does not match the Smile ID on the requested IP address."
 
 
 
 
 
 
 
7
  },
8
  "error": {
9
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
10
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
11
  "invalid_setup": "Add your Adam instead of your Anna, see the documentation",
 
 
12
  "response_error": "Invalid XML data, or error indication received",
13
+ "unknown": "[%key:common::config_flow::error::unknown%]",
 
14
  "unsupported": "Device with unsupported firmware"
15
  },
16
+ "step": {
17
+ "reconfigure": {
18
+ "data": {
19
+ "host": "[%key:common::config_flow::data::ip%]",
20
+ "port": "[%key:common::config_flow::data::port%]"
21
+ },
22
+ "data_description": {
23
+ "host": "[%key:component::plugwise::config::step::user::data_description::host%]",
24
+ "port": "[%key:component::plugwise::config::step::user::data_description::port%]"
25
+ },
26
+ "description": "Update configuration for {title}."
27
+ },
28
+ "user": {
29
+ "data": {
30
+ "host": "[%key:common::config_flow::data::ip%]",
31
+ "password": "Smile ID",
32
+ "port": "[%key:common::config_flow::data::port%]",
33
+ "username": "Smile username"
34
+ },
35
+ "data_description": {
36
+ "host": "The hostname or IP address of your Smile. You can find it in your router or the Plugwise app.",
37
+ "password": "The Smile ID printed on the label on the back of your Adam, Smile-T, or P1.",
38
+ "port": "By default your Smile uses port 80, normally you should not have to change this.",
39
+ "username": "Default is `smile`, or `stretch` for the legacy Stretch."
40
+ },
41
+ "description": "Please enter",
42
+ "title": "Connect to the Smile"
43
+ }
44
  }
45
  },
46
  "entity": {
 
51
  "cooling_enabled": {
52
  "name": "Cooling enabled"
53
  },
54
+ "cooling_state": {
55
+ "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]"
56
+ },
57
  "dhw_state": {
58
  "name": "DHW state"
59
  },
 
61
  "name": "Flame state"
62
  },
63
  "heating_state": {
64
+ "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]"
 
 
 
 
 
 
65
  },
66
  "plugwise_notification": {
67
  "name": "Plugwise notification"
68
+ },
69
+ "secondary_boiler_state": {
70
+ "name": "Secondary boiler state"
71
+ }
72
+ },
73
+ "button": {
74
+ "reboot": {
75
+ "name": "[%key:common::action::reboot%]"
76
  }
77
  },
78
  "climate": {
79
  "plugwise": {
80
  "state_attributes": {
81
+ "available_schemas": {
82
+ "name": "Available schemas"
83
+ },
84
  "preset_mode": {
85
  "state": {
86
  "asleep": "Night",
87
+ "away": "[%key:common::state::not_home%]",
88
+ "home": "[%key:common::state::home%]",
89
  "no_frost": "Anti-frost",
90
  "vacation": "Vacation"
91
  }
92
+ },
93
+ "selected_schema": {
94
+ "name": "Selected schema"
95
  }
96
  }
97
  }
98
  },
99
  "number": {
100
+ "asleep_preset_cooling_temperature": {
101
+ "name": "Night preset cooling temperature"
102
+ },
103
+ "asleep_preset_temperature": {
104
+ "name": "Night preset temperature"
105
+ },
106
+ "away_preset_cooling_temperature": {
107
+ "name": "Away preset cooling temperature"
108
+ },
109
+ "away_preset_temperature": {
110
+ "name": "Away preset temperature"
111
+ },
112
+ "home_preset_cooling_temperature": {
113
+ "name": "Home preset cooling temperature"
114
+ },
115
+ "home_preset_temperature": {
116
+ "name": "Home preset temperature"
117
  },
118
  "max_dhw_temperature": {
119
  "name": "Domestic hot water setpoint"
120
+ },
121
+ "maximum_boiler_temperature": {
122
+ "name": "Maximum boiler temperature setpoint"
123
+ },
124
+ "no_frost_preset_cooling_temperature": {
125
+ "name": "Anti-frost preset cooling temperature"
126
+ },
127
+ "no_frost_preset_temperature": {
128
+ "name": "Anti-frost preset temperature"
129
+ },
130
+ "temperature_offset": {
131
+ "name": "Temperature offset"
132
+ },
133
+ "vacation_preset_cooling_temperature": {
134
+ "name": "Vacation preset cooling temperature"
135
+ },
136
+ "vacation_preset_temperature": {
137
+ "name": "Vacation preset temperature"
138
  }
139
  },
140
  "select": {
141
+ "select_dhw_mode": {
142
  "name": "DHW mode",
143
  "state": {
144
+ "auto": "[%key:common::state::auto%]",
145
  "boost": "Boost",
146
  "comfort": "Comfort",
147
+ "eco": "Eco",
148
+ "off": "[%key:common::state::off%]"
149
  }
150
  },
151
+ "select_gateway_mode": {
152
+ "name": "Gateway mode",
153
+ "state": {
154
+ "away": "Pause",
155
+ "full": "[%key:common::state::normal%]",
156
+ "vacation": "Vacation"
157
+ }
158
+ },
159
+ "select_regulation_mode": {
160
  "name": "Regulation mode",
161
  "state": {
162
  "bleeding_cold": "Bleeding cold",
163
  "bleeding_hot": "Bleeding hot",
164
+ "cooling": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]",
165
+ "heating": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]",
166
+ "off": "[%key:common::state::off%]"
167
+ }
168
+ },
169
+ "select_schedule": {
170
+ "name": "Thermostat schedule",
171
+ "state": {
172
+ "off": "[%key:common::state::off%]"
173
  }
174
  },
175
+ "select_zone_profile": {
176
+ "name": "Zone profile",
177
+ "state": {
178
+ "active": "[%key:common::state::active%]",
179
+ "off": "[%key:common::state::off%]",
180
+ "passive": "Passive"
181
+ }
182
  }
183
  },
184
  "sensor": {
 
 
 
185
  "cooling_setpoint": {
186
  "name": "Cooling setpoint"
187
  },
188
+ "dhw_temperature": {
189
+ "name": "DHW temperature"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  },
191
+ "domestic_hot_water_setpoint": {
192
+ "name": "DHW setpoint"
193
  },
194
  "electricity_consumed": {
195
  "name": "Electricity consumed"
196
  },
 
 
 
 
 
 
 
 
 
197
  "electricity_consumed_interval": {
198
  "name": "Electricity consumed interval"
199
  },
200
+ "electricity_consumed_off_peak_cumulative": {
201
+ "name": "Electricity consumed off-peak cumulative"
202
  },
203
  "electricity_consumed_off_peak_interval": {
204
+ "name": "Electricity consumed off-peak interval"
 
 
 
 
 
 
 
 
 
205
  },
206
  "electricity_consumed_off_peak_point": {
207
+ "name": "Electricity consumed off-peak point"
 
 
 
 
 
 
208
  },
209
  "electricity_consumed_peak_cumulative": {
210
  "name": "Electricity consumed peak cumulative"
211
  },
212
+ "electricity_consumed_peak_interval": {
213
+ "name": "Electricity consumed peak interval"
 
 
 
214
  },
215
+ "electricity_consumed_peak_point": {
216
+ "name": "Electricity consumed peak point"
217
  },
218
+ "electricity_consumed_point": {
219
+ "name": "Electricity consumed point"
220
  },
221
  "electricity_phase_one_consumed": {
222
  "name": "Electricity phase one consumed"
223
  },
224
+ "electricity_phase_one_produced": {
225
+ "name": "Electricity phase one produced"
226
  },
227
  "electricity_phase_three_consumed": {
228
  "name": "Electricity phase three consumed"
229
  },
230
+ "electricity_phase_three_produced": {
231
+ "name": "Electricity phase three produced"
232
+ },
233
+ "electricity_phase_two_consumed": {
234
+ "name": "Electricity phase two consumed"
235
  },
236
  "electricity_phase_two_produced": {
237
  "name": "Electricity phase two produced"
238
  },
239
+ "electricity_produced": {
240
+ "name": "Electricity produced"
241
  },
242
+ "electricity_produced_interval": {
243
+ "name": "Electricity produced interval"
244
  },
245
+ "electricity_produced_off_peak_cumulative": {
246
+ "name": "Electricity produced off-peak cumulative"
247
  },
248
+ "electricity_produced_off_peak_interval": {
249
+ "name": "Electricity produced off-peak interval"
250
  },
251
+ "electricity_produced_off_peak_point": {
252
+ "name": "Electricity produced off-peak point"
253
+ },
254
+ "electricity_produced_peak_cumulative": {
255
+ "name": "Electricity produced peak cumulative"
256
+ },
257
+ "electricity_produced_peak_interval": {
258
+ "name": "Electricity produced peak interval"
259
+ },
260
+ "electricity_produced_peak_point": {
261
+ "name": "Electricity produced peak point"
262
+ },
263
+ "electricity_produced_point": {
264
+ "name": "Electricity produced point"
265
  },
266
  "gas_consumed_cumulative": {
267
  "name": "Gas consumed cumulative"
268
  },
269
+ "gas_consumed_interval": {
270
+ "name": "Gas consumed interval"
271
  },
272
+ "heating_setpoint": {
273
+ "name": "Heating setpoint"
274
  },
275
+ "intended_boiler_temperature": {
276
+ "name": "Intended boiler temperature"
277
  },
278
+ "maximum_boiler_temperature": {
279
+ "name": "Maximum boiler temperature"
280
  },
281
  "modulation_level": {
282
  "name": "Modulation level"
283
  },
284
+ "net_electricity_cumulative": {
285
+ "name": "Net electricity cumulative"
286
+ },
287
+ "net_electricity_point": {
288
+ "name": "Net electricity point"
289
+ },
290
+ "outdoor_air_temperature": {
291
+ "name": "Outdoor air temperature"
292
+ },
293
+ "outdoor_temperature": {
294
+ "name": "Outdoor temperature"
295
+ },
296
+ "return_temperature": {
297
+ "name": "Return temperature"
298
+ },
299
+ "setpoint": {
300
+ "name": "Setpoint"
301
+ },
302
+ "temperature_difference": {
303
+ "name": "Temperature difference"
304
+ },
305
  "valve_position": {
306
  "name": "Valve position"
307
  },
308
+ "voltage_phase_one": {
309
+ "name": "Voltage phase one"
310
  },
311
+ "voltage_phase_three": {
312
+ "name": "Voltage phase three"
313
  },
314
+ "voltage_phase_two": {
315
+ "name": "Voltage phase two"
316
  },
317
+ "water_pressure": {
318
+ "name": "Water pressure"
319
  },
320
+ "water_temperature": {
321
+ "name": "Water temperature"
322
  }
323
  },
324
  "switch": {
325
  "cooling_ena_switch": {
326
+ "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]"
327
  },
328
  "dhw_cm_switch": {
329
  "name": "DHW comfort mode"
330
  },
331
  "lock": {
332
+ "name": "[%key:component::lock::title%]"
333
  },
334
  "relay": {
335
  "name": "Relay"
336
  }
337
+ },
338
+ "water_heater": {
339
+ "dhw_temperature": {
340
+ "name": "Domestic hot water"
341
+ }
342
+ }
343
+ },
344
+ "exceptions": {
345
+ "authentication_failed": {
346
+ "message": "[%key:common::config_flow::error::invalid_auth%]"
347
+ },
348
+ "data_incomplete_or_missing": {
349
+ "message": "Data incomplete or missing."
350
+ },
351
+ "error_communicating_with_api": {
352
+ "message": "Error communicating with API: {error}."
353
+ },
354
+ "failed_to_connect": {
355
+ "message": "[%key:common::config_flow::error::cannot_connect%]"
356
+ },
357
+ "invalid_setup": {
358
+ "message": "Add your Adam instead of your Anna, see the documentation"
359
+ },
360
+ "response_error": {
361
+ "message": "[%key:component::plugwise::config::error::response_error%]"
362
+ },
363
+ "set_schedule_first": {
364
+ "message": "Failed setting HVACMode, set a schedule first."
365
+ },
366
+ "temperature_out_of_range": {
367
+ "message": "Temperature must be between {min_temp} and {max_temp} {temperature_unit}."
368
+ },
369
+ "unsupported_firmware": {
370
+ "message": "[%key:component::plugwise::config::error::unsupported%]"
371
+ }
372
+ },
373
+ "issues": {
374
+ "deprecated_dhw_cm_switch": {
375
+ "description": "The `{entity_id}` ({entity_name}) switch is deprecated and will be removed in Home Assistant 2027.5.0. It has been replaced by the **DHW mode** select, which provides the available modes for your heater.\n\nUpdate dashboards, templates, automations, and scripts to use the DHW mode select and choose the required mode. Then disable `{entity_id}` in Settings > Devices & services > Entities. Home Assistant will remove it after the next restart.",
376
+ "title": "The Plugwise DHW comfort switch is deprecated"
377
+ },
378
+ "deprecated_dhw_cm_switch_scripts": {
379
+ "description": "The `{entity_id}` ({entity_name}) switch is deprecated and will be removed in Home Assistant 2027.5.0. It has been replaced by the **DHW mode** select, which provides the available modes for your heater.\n\nIt is used in the following automations or scripts:\n{items}\n\nUpdate these automations or scripts to use the DHW mode select and choose the required mode, then disable `{entity_id}` in Settings > Devices & services > Entities. Home Assistant will remove it after the next restart.",
380
+ "title": "[%key:component::plugwise::issues::deprecated_dhw_cm_switch::title%]"
381
+ },
382
+ "deprecated_dhw_temperature": {
383
+ "description": "The `{entity_id}` ({entity_name}) number is deprecated and will be removed in Home Assistant 2027.5.0. Its setpoint is now provided by the **Domestic hot water** water heater entity.\n\nUpdate dashboards, templates, automations, and scripts to use the water heater entity and its set-temperature control. Then disable `{entity_id}` in Settings > Devices & services > Entities. Home Assistant will remove it after the next restart.",
384
+ "title": "The Plugwise DHW temperature number is deprecated"
385
+ },
386
+ "deprecated_dhw_temperature_scripts": {
387
+ "description": "The `{entity_id}` ({entity_name}) number is deprecated and will be removed in Home Assistant 2027.5.0. Its setpoint is now provided by the **Domestic hot water** water heater entity.\n\nIt is used in the following automations or scripts:\n{items}\n\nUpdate these automations or scripts to use the water heater entity and its set-temperature control, then disable `{entity_id}` in Settings > Devices & services > Entities. Home Assistant will remove it after the next restart.",
388
+ "title": "[%key:component::plugwise::issues::deprecated_dhw_temperature::title%]"
389
  }
390
  }
391
  }
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/switch.py RENAMED
@@ -1,42 +1,103 @@
1
  """Plugwise Switch component for HomeAssistant."""
2
- from __future__ import annotations
3
 
4
- from typing import Any
 
5
 
6
- from homeassistant.components.switch import SwitchEntity
7
- from homeassistant.config_entries import ConfigEntry
8
- from homeassistant.core import HomeAssistant
9
- from homeassistant.helpers.entity_platform import AddEntitiesCallback
10
-
11
- from .const import (
12
- COORDINATOR, # pw-beta
13
- DOMAIN,
14
- LOGGER,
15
  )
16
- from .coordinator import PlugwiseDataUpdateCoordinator
 
 
 
 
 
17
  from .entity import PlugwiseEntity
18
- from .models import PW_SWITCH_TYPES, PlugwiseSwitchEntityDescription
19
- from .util import plugwise_command
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
  async def async_setup_entry(
23
  hass: HomeAssistant,
24
- config_entry: ConfigEntry,
25
- async_add_entities: AddEntitiesCallback,
26
  ) -> None:
27
  """Set up the Smile switches from a config entry."""
28
- coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
29
- entities: list[PlugwiseSwitchEntity] = []
30
- for device_id, device in coordinator.data.devices.items():
31
- if not (switches := device.get("switches")):
32
- continue
33
- for description in PW_SWITCH_TYPES:
34
- if description.key not in switches:
 
 
 
 
 
35
  continue
36
- entities.append(PlugwiseSwitchEntity(coordinator, device_id, description))
37
- LOGGER.debug("Add %s switch", description.key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- async_add_entities(entities)
 
40
 
41
 
42
  class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity):
@@ -52,15 +113,17 @@
52
  ) -> None:
53
  """Set up the Plugwise API."""
54
  super().__init__(coordinator, device_id)
55
- self.entity_description = description
56
  self._attr_unique_id = f"{device_id}-{description.key}"
 
57
 
58
  @property
 
59
  def is_on(self) -> bool:
60
  """Return True if entity is on."""
61
- return self.entity_description.value_fn(self.device["switches"])
62
 
63
  @plugwise_command
 
64
  async def async_turn_on(self, **kwargs: Any) -> None:
65
  """Turn the device on."""
66
  await self.coordinator.api.set_switch_state(
@@ -71,6 +134,7 @@
71
  )
72
 
73
  @plugwise_command
 
74
  async def async_turn_off(self, **kwargs: Any) -> None:
75
  """Turn the device off."""
76
  await self.coordinator.api.set_switch_state(
@@ -79,3 +143,17 @@
79
  self.entity_description.key,
80
  "off",
81
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Plugwise Switch component for HomeAssistant."""
 
2
 
3
+ from dataclasses import dataclass
4
+ from typing import Any, override
5
 
6
+ from plugwise.constants import SwitchType
7
+
8
+ from homeassistant.components.switch import (
9
+ SwitchDeviceClass,
10
+ SwitchEntity,
11
+ SwitchEntityDescription,
 
 
 
12
  )
13
+ from homeassistant.const import EntityCategory, Platform
14
+ from homeassistant.core import HomeAssistant, callback
15
+ from homeassistant.helpers import entity_registry as er
16
+ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
17
+
18
+ from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
19
  from .entity import PlugwiseEntity
20
+ from .util import deprecate_entity, plugwise_command
21
+
22
+ PARALLEL_UPDATES = 0
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class PlugwiseSwitchEntityDescription(SwitchEntityDescription):
27
+ """Describes Plugwise switch entity."""
28
+
29
+ key: SwitchType
30
+
31
+
32
+ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = (
33
+ PlugwiseSwitchEntityDescription(
34
+ key="dhw_cm_switch",
35
+ translation_key="dhw_cm_switch",
36
+ entity_category=EntityCategory.CONFIG,
37
+ ),
38
+ PlugwiseSwitchEntityDescription(
39
+ key="lock",
40
+ translation_key="lock",
41
+ entity_category=EntityCategory.CONFIG,
42
+ ),
43
+ PlugwiseSwitchEntityDescription(
44
+ key="relay",
45
+ translation_key="relay",
46
+ device_class=SwitchDeviceClass.SWITCH,
47
+ ),
48
+ PlugwiseSwitchEntityDescription(
49
+ key="cooling_ena_switch",
50
+ translation_key="cooling_ena_switch",
51
+ entity_category=EntityCategory.CONFIG,
52
+ ),
53
+ )
54
 
55
 
56
  async def async_setup_entry(
57
  hass: HomeAssistant,
58
+ entry: PlugwiseConfigEntry,
59
+ async_add_entities: AddConfigEntryEntitiesCallback,
60
  ) -> None:
61
  """Set up the Smile switches from a config entry."""
62
+ coordinator = entry.runtime_data
63
+ entity_registry = er.async_get(hass)
64
+
65
+ @callback
66
+ def _add_entities() -> None:
67
+ """Add Entities."""
68
+ if not coordinator.new_devices:
69
+ return
70
+
71
+ entities: list[SwitchEntity] = []
72
+ for device_id in coordinator.new_devices:
73
+ if not (switches := coordinator.data[device_id].get("switches")):
74
  continue
75
+ for description in SWITCHES:
76
+ if description.key not in switches:
77
+ continue
78
+ if description.key == "dhw_cm_switch":
79
+ if not deprecate_entity(
80
+ hass,
81
+ entity_registry,
82
+ async_on_unload=entry.async_on_unload,
83
+ platform_domain=Platform.SWITCH,
84
+ entity_unique_id=f"{device_id}-dhw_cm_switch",
85
+ issue_id=f"deprecated_dhw_cm_switch_{device_id}",
86
+ translation_key="deprecated_dhw_cm_switch",
87
+ ):
88
+ continue
89
+ entities.append(
90
+ PlugwiseDhwCmSwitchEntity(coordinator, device_id, description)
91
+ )
92
+ else:
93
+ entities.append(
94
+ PlugwiseSwitchEntity(coordinator, device_id, description)
95
+ )
96
+
97
+ async_add_entities(entities)
98
 
99
+ _add_entities()
100
+ entry.async_on_unload(coordinator.async_add_listener(_add_entities))
101
 
102
 
103
  class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity):
 
113
  ) -> None:
114
  """Set up the Plugwise API."""
115
  super().__init__(coordinator, device_id)
 
116
  self._attr_unique_id = f"{device_id}-{description.key}"
117
+ self.entity_description = description
118
 
119
  @property
120
+ @override
121
  def is_on(self) -> bool:
122
  """Return True if entity is on."""
123
+ return self.device["switches"][self.entity_description.key]
124
 
125
  @plugwise_command
126
+ @override
127
  async def async_turn_on(self, **kwargs: Any) -> None:
128
  """Turn the device on."""
129
  await self.coordinator.api.set_switch_state(
 
134
  )
135
 
136
  @plugwise_command
137
+ @override
138
  async def async_turn_off(self, **kwargs: Any) -> None:
139
  """Turn the device off."""
140
  await self.coordinator.api.set_switch_state(
 
143
  self.entity_description.key,
144
  "off",
145
  )
146
+
147
+
148
+ class PlugwiseDhwCmSwitchEntity(PlugwiseSwitchEntity):
149
+ """Represent the deprecated DHW comfort switch."""
150
+
151
+ @override
152
+ async def async_turn_on(self, **kwargs: Any) -> None:
153
+ """Turn the deprecated DHW comfort switch on."""
154
+ await super().async_turn_on(**kwargs)
155
+
156
+ @override
157
+ async def async_turn_off(self, **kwargs: Any) -> None:
158
+ """Turn the deprecated DHW comfort switch off."""
159
+ await super().async_turn_off(**kwargs)
/home/runner/work/progress/progress/clones/beta/{beta/custom_components → ha-core/homeassistant/components}/plugwise/util.py RENAMED
@@ -1,21 +1,185 @@
1
  """Utilities for Plugwise."""
 
2
  from collections.abc import Awaitable, Callable, Coroutine
3
- from typing import Any, Concatenate, TypeVar
4
 
5
- from typing_extensions import ParamSpec
6
 
 
 
 
7
  from homeassistant.exceptions import HomeAssistantError
8
- from plugwise.exceptions import PlugwiseException
 
 
 
 
 
 
9
 
 
10
  from .entity import PlugwiseEntity
11
 
12
- _PlugwiseEntityT = TypeVar("_PlugwiseEntityT", bound=PlugwiseEntity)
13
- _R = TypeVar("_R")
14
- _P = ParamSpec("_P")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
- def plugwise_command(
18
- func: Callable[Concatenate[_PlugwiseEntityT, _P], Awaitable[_R]]
19
  ) -> Callable[Concatenate[_PlugwiseEntityT, _P], Coroutine[Any, Any, _R]]:
20
  """Decorate Plugwise calls that send commands/make changes to the device.
21
 
@@ -28,10 +192,14 @@
28
  ) -> _R:
29
  try:
30
  return await func(self, *args, **kwargs)
31
- except PlugwiseException as error:
32
  raise HomeAssistantError(
33
- f"Error communicating with API: {error}"
34
- ) from error
 
 
 
 
35
  finally:
36
  await self.coordinator.async_request_refresh()
37
 
 
1
  """Utilities for Plugwise."""
2
+
3
  from collections.abc import Awaitable, Callable, Coroutine
4
+ from typing import Any, Concatenate
5
 
6
+ from plugwise.exceptions import PlugwiseException
7
 
8
+ from homeassistant.components.automation import automations_with_entity
9
+ from homeassistant.components.script import scripts_with_entity
10
+ from homeassistant.core import CoreState, HomeAssistant, callback
11
  from homeassistant.exceptions import HomeAssistantError
12
+ from homeassistant.helpers import entity_registry as er
13
+ from homeassistant.helpers.issue_registry import (
14
+ IssueSeverity,
15
+ async_create_issue,
16
+ async_delete_issue,
17
+ )
18
+ from homeassistant.helpers.start import async_at_started
19
 
20
+ from .const import DOMAIN
21
  from .entity import PlugwiseEntity
22
 
23
+ # Version in which deprecated entities will be removed.
24
+ DEPRECATED_REMOVAL_VERSION = "2027.5.0"
25
+
26
+
27
+ def deprecate_entity(
28
+ hass: HomeAssistant,
29
+ entity_registry: er.EntityRegistry,
30
+ *,
31
+ async_on_unload: Callable[[Callable[[], None]], None],
32
+ platform_domain: str,
33
+ entity_unique_id: str,
34
+ issue_id: str,
35
+ translation_key: str,
36
+ ) -> bool:
37
+ """Handle deprecation of an entity that has been replaced.
38
+
39
+ Return True if the deprecated entity should still be set up, which is the
40
+ case while it exists in the entity registry. A repair issue informs the user
41
+ about the replacement and the removal date; when the entity is still used by
42
+ automations or scripts they are listed in the issue. The entity is removed
43
+ once the user disables it and nothing references it anymore. New
44
+ installations never create the entity.
45
+ """
46
+ entity_id = entity_registry.async_get_entity_id(
47
+ platform_domain, DOMAIN, entity_unique_id
48
+ )
49
+ if entity_id is None:
50
+ async_delete_issue(hass, DOMAIN, issue_id)
51
+ return False
52
+
53
+ entity_entry = entity_registry.async_get(entity_id)
54
+ if entity_entry is None:
55
+ async_delete_issue(hass, DOMAIN, issue_id)
56
+ return False
57
+
58
+ if hass.state is not CoreState.running:
59
+ _async_defer_deprecation_check(
60
+ hass,
61
+ entity_registry,
62
+ async_on_unload=async_on_unload,
63
+ platform_domain=platform_domain,
64
+ entity_unique_id=entity_unique_id,
65
+ issue_id=issue_id,
66
+ translation_key=translation_key,
67
+ )
68
+ return True
69
+
70
+ return _async_check_deprecated_entity(
71
+ hass,
72
+ entity_registry,
73
+ entity_id=entity_id,
74
+ issue_id=issue_id,
75
+ translation_key=translation_key,
76
+ )
77
+
78
+
79
+ @callback
80
+ def _async_check_deprecated_entity(
81
+ hass: HomeAssistant,
82
+ entity_registry: er.EntityRegistry,
83
+ *,
84
+ entity_id: str,
85
+ issue_id: str,
86
+ translation_key: str,
87
+ ) -> bool:
88
+ """Check whether a deprecated entity should remain in the registry."""
89
+ entity_entry = entity_registry.async_get(entity_id)
90
+ if entity_entry is None:
91
+ async_delete_issue(hass, DOMAIN, issue_id)
92
+ return False
93
+
94
+ items = _automations_and_scripts_using_entity(hass, entity_registry, entity_id)
95
+
96
+ if entity_entry.disabled and not items:
97
+ entity_registry.async_remove(entity_id)
98
+ async_delete_issue(hass, DOMAIN, issue_id)
99
+ return False
100
+
101
+ placeholders = {
102
+ "entity_id": entity_id,
103
+ "entity_name": entity_entry.name or entity_entry.original_name or entity_id,
104
+ }
105
+ if items:
106
+ translation_key = f"{translation_key}_scripts"
107
+ placeholders["items"] = "\n".join(items)
108
+
109
+ async_create_issue(
110
+ hass,
111
+ DOMAIN,
112
+ issue_id,
113
+ breaks_in_ha_version=DEPRECATED_REMOVAL_VERSION,
114
+ config_entry_id=entity_entry.config_entry_id,
115
+ is_fixable=False,
116
+ severity=IssueSeverity.WARNING,
117
+ translation_key=translation_key,
118
+ translation_placeholders=placeholders,
119
+ )
120
+ return True
121
+
122
+
123
+ def _automations_and_scripts_using_entity(
124
+ hass: HomeAssistant,
125
+ entity_registry: er.EntityRegistry,
126
+ entity_id: str,
127
+ ) -> list[str]:
128
+ """Return markdown list items for automations and scripts."""
129
+ automations = automations_with_entity(hass, entity_id)
130
+ scripts = scripts_with_entity(hass, entity_id)
131
+ if not automations and not scripts:
132
+ return []
133
+
134
+ items: list[str] = []
135
+ for integration, used_entities in (
136
+ ("automation", automations),
137
+ ("script", scripts),
138
+ ):
139
+ for used_entity_id in used_entities:
140
+ if entry := entity_registry.async_get(used_entity_id):
141
+ items.append(
142
+ f"- [{entry.original_name}](/config/{integration}/edit/{entry.unique_id})"
143
+ )
144
+ else:
145
+ items.append(f"- `{used_entity_id}`")
146
+
147
+ return items
148
+
149
+
150
+ @callback
151
+ def _async_defer_deprecation_check(
152
+ hass: HomeAssistant,
153
+ entity_registry: er.EntityRegistry,
154
+ *,
155
+ async_on_unload: Callable[[Callable[[], None]], None],
156
+ platform_domain: str,
157
+ entity_unique_id: str,
158
+ issue_id: str,
159
+ translation_key: str,
160
+ ) -> None:
161
+ """Recheck entity references once Home Assistant has started."""
162
+
163
+ @callback
164
+ def _async_recheck(_: HomeAssistant) -> None:
165
+ if entity_id := entity_registry.async_get_entity_id(
166
+ platform_domain, DOMAIN, entity_unique_id
167
+ ):
168
+ _async_check_deprecated_entity(
169
+ hass,
170
+ entity_registry,
171
+ entity_id=entity_id,
172
+ issue_id=issue_id,
173
+ translation_key=translation_key,
174
+ )
175
+ else:
176
+ async_delete_issue(hass, DOMAIN, issue_id)
177
+
178
+ async_on_unload(async_at_started(hass, _async_recheck))
179
 
180
 
181
+ def plugwise_command[_PlugwiseEntityT: PlugwiseEntity, **_P, _R](
182
+ func: Callable[Concatenate[_PlugwiseEntityT, _P], Awaitable[_R]],
183
  ) -> Callable[Concatenate[_PlugwiseEntityT, _P], Coroutine[Any, Any, _R]]:
184
  """Decorate Plugwise calls that send commands/make changes to the device.
185
 
 
192
  ) -> _R:
193
  try:
194
  return await func(self, *args, **kwargs)
195
+ except PlugwiseException as err:
196
  raise HomeAssistantError(
197
+ translation_domain=DOMAIN,
198
+ translation_key="error_communicating_with_api",
199
+ translation_placeholders={
200
+ "error": str(err),
201
+ },
202
+ ) from err
203
  finally:
204
  await self.coordinator.async_request_refresh()
205