Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Amber Electric Sensor definitions."""
2 
3 # There are three types of sensor: Current, Forecast and Grid
4 # Current and forecast will create general, controlled load and feed in as required
5 # At the moment renewables in the only grid sensor.
6 
7 from __future__ import annotations
8 
9 from typing import Any
10 
11 from amberelectric.models.channel import ChannelType
12 from amberelectric.models.current_interval import CurrentInterval
13 from amberelectric.models.forecast_interval import ForecastInterval
14 
16  SensorEntity,
17  SensorEntityDescription,
18  SensorStateClass,
19 )
20 from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, UnitOfEnergy
21 from homeassistant.core import HomeAssistant
22 from homeassistant.helpers.entity_platform import AddEntitiesCallback
23 from homeassistant.helpers.update_coordinator import CoordinatorEntity
24 
25 from . import AmberConfigEntry
26 from .const import ATTRIBUTION
27 from .coordinator import AmberUpdateCoordinator, normalize_descriptor
28 
29 UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}"
30 
31 
32 def format_cents_to_dollars(cents: float) -> float:
33  """Return a formatted conversion from cents to dollars."""
34  return round(cents / 100, 2)
35 
36 
37 def friendly_channel_type(channel_type: str) -> str:
38  """Return a human readable version of the channel type."""
39  if channel_type == "controlled_load":
40  return "Controlled Load"
41  if channel_type == "feed_in":
42  return "Feed In"
43  return "General"
44 
45 
46 class AmberSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity):
47  """Amber Base Sensor."""
48 
49  _attr_attribution = ATTRIBUTION
50 
51  def __init__(
52  self,
53  coordinator: AmberUpdateCoordinator,
54  description: SensorEntityDescription,
55  channel_type: str,
56  ) -> None:
57  """Initialize the Sensor."""
58  super().__init__(coordinator)
59  self.site_idsite_idsite_id = coordinator.site_id
60  self.entity_descriptionentity_description = description
61  self.channel_typechannel_type = channel_type
62 
63  self._attr_unique_id_attr_unique_id = (
64  f"{self.site_id}-{self.entity_description.key}-{self.channel_type}"
65  )
66 
67 
69  """Amber Price Sensor."""
70 
71  @property
72  def native_value(self) -> float | None:
73  """Return the current price in $/kWh."""
74  interval = self.coordinator.data[self.entity_descriptionentity_description.key][self.channel_typechannel_type]
75 
76  if interval.channel_type == ChannelType.FEEDIN:
77  return format_cents_to_dollars(interval.per_kwh) * -1
78  return format_cents_to_dollars(interval.per_kwh)
79 
80  @property
81  def extra_state_attributes(self) -> dict[str, Any] | None:
82  """Return additional pieces of information about the price."""
83  interval = self.coordinator.data[self.entity_descriptionentity_description.key][self.channel_typechannel_type]
84 
85  data: dict[str, Any] = {}
86  if interval is None:
87  return data
88 
89  data["duration"] = interval.duration
90  data["date"] = interval.var_date.isoformat()
91  data["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
92  if interval.channel_type == ChannelType.FEEDIN:
93  data["per_kwh"] = data["per_kwh"] * -1
94  data["nem_date"] = interval.nem_time.isoformat()
95  data["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
96  data["start_time"] = interval.start_time.isoformat()
97  data["end_time"] = interval.end_time.isoformat()
98  data["renewables"] = round(interval.renewables)
99  data["estimate"] = interval.estimate
100  data["spike_status"] = interval.spike_status.value
101  data["channel_type"] = interval.channel_type.value
102 
103  if interval.range is not None:
104  data["range_min"] = format_cents_to_dollars(interval.range.min)
105  data["range_max"] = format_cents_to_dollars(interval.range.max)
106 
107  return data
108 
109 
111  """Amber Forecast Sensor."""
112 
113  @property
114  def native_value(self) -> float | None:
115  """Return the first forecast price in $/kWh."""
116  intervals = self.coordinator.data[self.entity_descriptionentity_description.key].get(
117  self.channel_typechannel_type
118  )
119  if not intervals:
120  return None
121  interval = intervals[0]
122 
123  if interval.channel_type == ChannelType.FEEDIN:
124  return format_cents_to_dollars(interval.per_kwh) * -1
125  return format_cents_to_dollars(interval.per_kwh)
126 
127  @property
128  def extra_state_attributes(self) -> dict[str, Any] | None:
129  """Return additional pieces of information about the price."""
130  intervals = self.coordinator.data[self.entity_descriptionentity_description.key].get(
131  self.channel_typechannel_type
132  )
133 
134  if not intervals:
135  return None
136 
137  data = {
138  "forecasts": [],
139  "channel_type": intervals[0].channel_type.value,
140  }
141 
142  for interval in intervals:
143  datum = {}
144  datum["duration"] = interval.duration
145  datum["date"] = interval.var_date.isoformat()
146  datum["nem_date"] = interval.nem_time.isoformat()
147  datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
148  if interval.channel_type == ChannelType.FEEDIN:
149  datum["per_kwh"] = datum["per_kwh"] * -1
150  datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
151  datum["start_time"] = interval.start_time.isoformat()
152  datum["end_time"] = interval.end_time.isoformat()
153  datum["renewables"] = round(interval.renewables)
154  datum["spike_status"] = interval.spike_status.value
155  datum["descriptor"] = normalize_descriptor(interval.descriptor)
156 
157  if interval.range is not None:
158  datum["range_min"] = format_cents_to_dollars(interval.range.min)
159  datum["range_max"] = format_cents_to_dollars(interval.range.max)
160 
161  data["forecasts"].append(datum)
162 
163  return data
164 
165 
167  """Amber Price Descriptor Sensor."""
168 
169  @property
170  def native_value(self) -> str | None:
171  """Return the current price descriptor."""
172  return self.coordinator.data[self.entity_descriptionentity_description.key][self.channel_typechannel_type] # type: ignore[no-any-return]
173 
174 
175 class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity):
176  """Sensor to show single grid specific values."""
177 
178  _attr_attribution = ATTRIBUTION
179 
180  def __init__(
181  self,
182  coordinator: AmberUpdateCoordinator,
183  description: SensorEntityDescription,
184  ) -> None:
185  """Initialize the Sensor."""
186  super().__init__(coordinator)
187  self.site_idsite_idsite_id = coordinator.site_id
188  self.entity_descriptionentity_description = description
189  self._attr_unique_id_attr_unique_id = f"{coordinator.site_id}-{description.key}"
190 
191  @property
192  def native_value(self) -> str | None:
193  """Return the value of the sensor."""
194  return self.coordinator.data["grid"][self.entity_descriptionentity_description.key] # type: ignore[no-any-return]
195 
196 
198  hass: HomeAssistant,
199  entry: AmberConfigEntry,
200  async_add_entities: AddEntitiesCallback,
201 ) -> None:
202  """Set up a config entry."""
203  coordinator = entry.runtime_data
204 
205  current: dict[str, CurrentInterval] = coordinator.data["current"]
206  forecasts: dict[str, list[ForecastInterval]] = coordinator.data["forecasts"]
207 
208  entities: list[SensorEntity] = []
209  for channel_type in current:
210  description = SensorEntityDescription(
211  key="current",
212  name=f"{entry.title} - {friendly_channel_type(channel_type)} Price",
213  native_unit_of_measurement=UNIT,
214  state_class=SensorStateClass.MEASUREMENT,
215  translation_key=channel_type,
216  )
217  entities.append(AmberPriceSensor(coordinator, description, channel_type))
218 
219  for channel_type in current:
220  description = SensorEntityDescription(
221  key="descriptors",
222  name=(
223  f"{entry.title} - {friendly_channel_type(channel_type)} Price"
224  " Descriptor"
225  ),
226  translation_key=channel_type,
227  )
228  entities.append(
229  AmberPriceDescriptorSensor(coordinator, description, channel_type)
230  )
231 
232  for channel_type in forecasts:
233  description = SensorEntityDescription(
234  key="forecasts",
235  name=f"{entry.title} - {friendly_channel_type(channel_type)} Forecast",
236  native_unit_of_measurement=UNIT,
237  state_class=SensorStateClass.MEASUREMENT,
238  translation_key=channel_type,
239  )
240  entities.append(AmberForecastSensor(coordinator, description, channel_type))
241 
242  renewables_description = SensorEntityDescription(
243  key="renewables",
244  name=f"{entry.title} - Renewables",
245  native_unit_of_measurement=PERCENTAGE,
246  state_class=SensorStateClass.MEASUREMENT,
247  translation_key="renewables",
248  )
249  entities.append(AmberGridSensor(coordinator, renewables_description))
250 
251  async_add_entities(entities)
None __init__(self, AmberUpdateCoordinator coordinator, SensorEntityDescription description)
Definition: sensor.py:184
None __init__(self, AmberUpdateCoordinator coordinator, SensorEntityDescription description, str channel_type)
Definition: sensor.py:56
str|None normalize_descriptor(PriceDescriptor|None descriptor)
Definition: coordinator.py:49
float format_cents_to_dollars(float cents)
Definition: sensor.py:32
None async_setup_entry(HomeAssistant hass, AmberConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: sensor.py:201
str friendly_channel_type(str channel_type)
Definition: sensor.py:37
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88