Skip to content

Commit 8bc9f89

Browse files
Merge pull request #69 from BottlecapDave/develop
Next release
2 parents e27da99 + 2133b5f commit 8bc9f89

20 files changed

Lines changed: 772 additions & 77 deletions

_docs/events.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
11
# Events
22

3-
The following events are raised by the integration. These events power various entities and can also be used within automations.
3+
The following events are either raised or received by the integration. These events power various entities and can also be used within automations.
4+
5+
## Update Data Source
6+
7+
`target_timeframe_update_data_source`
8+
9+
Data sources listen for this event and then update the data based on the provided data if the `data_source_id` matches the data source's id.
10+
11+
| Attribute | Type | Description |
12+
|-----------|------|-------------|
13+
| `data_source_id` | `string` | The id of the data source the data belongs to |
14+
| `data` | `array` | The data to update the data source with |
15+
16+
For each item in `data`, the following attributes should be present
17+
18+
| Attribute | Type | Description |
19+
|-----------|------|-------------|
20+
| `start` | `datetime` | The start timestamp the value is effective from |
21+
| `end` | `datetime` | The end timestamp the value is effective to |
22+
| `value` | `float` | The value that is applicable for the timeframe. This could be something like an electricity rate |
23+
| `metadata` | `object` | Additional metadata that might describe how the value was created |

_docs/setup/data_source.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ The name of the data source. This is for informative purposes.
1010

1111
### Id
1212

13-
The unique identifier of the data source. This is used by internal events to ensure sensors use the correct data as well as part of the entity name of all related entities. For example, if you were using data provided by Octopus Energy, then this might be `octopus_energy`.
13+
The unique identifier of the data source. This is used by internal events to ensure sensors use the correct data as well as part of the entity name of all related entities.
1414

15-
Once the data source has been created, you'll have the following entities created. You'll then need to use the [available service](../services.md#target_timeframesupdate_target_timeframe_data_source) to configure the underlying data. There are also a collection of [blueprints](../blueprints.md#data-sources) available for loading popular data sources.
15+
The data source will listen for the [update data source event](../events.md#update-data-source) where the `data source id` matches this id. This event might be raised by other integrations. If this data source is being used for this purpose, then the id will need to be set to a certain value which should be highlighted in that integrations guide.
16+
17+
Alternatively, you can use the [available service](../services.md#target_timeframesupdate_target_timeframe_data_source) to configure the underlying data. There is a collection of [blueprints](../blueprints.md#data-sources) available for loading data from popular data sources.
1618

1719
## Entities
1820

21+
Once the data source has been created, you'll have the following entities created.
22+
1923
### Data Source Last Updated
2024

2125
`sensor.target_timeframes_{{DATA_SOURCE_ID}}_data_source_last_updated`

_docs/setup/rolling_target_timeframe.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ There may be times that you want the target timeframe sensors to not take into a
100100

101101
!!! info
102102

103-
This is only available for **continuous** target value sensors in **exact** hours mode.
103+
This is only available for **continuous** target value sensors.
104104

105105
There may be times when the device you're wanting the target value sensor to turn on doesn't have a consistent power draw. You can specify a weighting/multiplier which can be applied to the value of each discovered 30 minute slot. This can be specified in a few different ways. Take the following example weighting/multiplier for a required 2 hours.
106106

_docs/setup/target_timeframe.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ There may be times that you want the target timeframe sensors to not take into a
128128

129129
!!! info
130130

131-
This is only available for **continuous** target value sensors in **exact** hours mode.
131+
This is only available for **continuous** target value sensors.
132132

133133
There may be times when the device you're wanting the target value sensor to turn on doesn't have a consistent power draw. You can specify a weighting/multiplier which can be applied to the value of each discovered 30 minute slot. This can be specified in a few different ways. Take the following example weighting/multiplier for a required 2 hours.
134134

custom_components/target_timeframes/config/rolling_target_timeframe.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
CONFIG_TARGET_HOURS,
77
CONFIG_TARGET_HOURS_MODE,
88
CONFIG_TARGET_HOURS_MODE_EXACT,
9+
CONFIG_TARGET_HOURS_MODE_MAXIMUM,
910
CONFIG_TARGET_HOURS_MODE_MINIMUM,
1011
CONFIG_TARGET_MAX_VALUE,
1112
CONFIG_TARGET_MIN_VALUE,
@@ -120,14 +121,19 @@ def validate_rolling_target_timeframe_config(data):
120121
number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2)
121122
weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots)
122123

123-
if (len(weighting) != number_of_slots):
124-
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
124+
if (weighting is None or len(weighting) != number_of_slots):
125+
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
126+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_minimum_weighting_slots"
127+
elif CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MAXIMUM:
128+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_maximum_weighting_slots"
129+
else:
130+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
131+
132+
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT and "*" not in data[CONFIG_TARGET_WEIGHTING]:
133+
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_varied_for_hour_mode"
125134

126135
if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS:
127136
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_type"
128-
129-
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT:
130-
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_hour_mode"
131137

132138
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
133139
if (CONFIG_TARGET_MIN_VALUE not in data or data[CONFIG_TARGET_MIN_VALUE] is None) and (CONFIG_TARGET_MAX_VALUE not in data or data[CONFIG_TARGET_MAX_VALUE] is None):

custom_components/target_timeframes/config/target_timeframe.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
CONFIG_TARGET_HOURS,
1010
CONFIG_TARGET_HOURS_MODE,
1111
CONFIG_TARGET_HOURS_MODE_EXACT,
12+
CONFIG_TARGET_HOURS_MODE_MAXIMUM,
1213
CONFIG_TARGET_HOURS_MODE_MINIMUM,
1314
CONFIG_TARGET_MAX_VALUE,
1415
CONFIG_TARGET_MIN_VALUE,
@@ -146,14 +147,19 @@ def validate_target_timeframe_config(data):
146147
number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2)
147148
weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots)
148149

149-
if (len(weighting) != number_of_slots):
150-
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
150+
if (weighting is None or len(weighting) != number_of_slots):
151+
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
152+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_minimum_weighting_slots"
153+
elif CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MAXIMUM:
154+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_maximum_weighting_slots"
155+
else:
156+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
157+
158+
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT and "*" not in data[CONFIG_TARGET_WEIGHTING]:
159+
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_varied_for_hour_mode"
151160

152161
if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS:
153162
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_type"
154-
155-
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT:
156-
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_hour_mode"
157163

158164
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
159165
if (CONFIG_TARGET_MIN_VALUE not in data or data[CONFIG_TARGET_MIN_VALUE] is None) and (CONFIG_TARGET_MAX_VALUE not in data or data[CONFIG_TARGET_MAX_VALUE] is None):

custom_components/target_timeframes/config_flow.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222

2323
from .config.data_source import validate_source_config
2424

25+
description_placeholders = {
26+
"setup_data_source_docs_url": "https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/data_source",
27+
"setup_target_timeframe_docs_url": "https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/target_timeframe",
28+
"setup_rolling_target_timeframe_docs_url": "https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/rolling_target_timeframe",
29+
}
30+
2531
class TargetTimeframesConfigFlow(ConfigFlow, domain=DOMAIN):
2632
"""Config flow."""
2733

@@ -48,7 +54,8 @@ async def async_step_user(self, user_input):
4854
return self.async_show_form(
4955
step_id="user",
5056
data_schema=DATA_SCHEMA_SOURCE,
51-
errors=errors
57+
errors=errors,
58+
description_placeholders=description_placeholders
5259
)
5360

5461
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
@@ -76,7 +83,8 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None)
7683
DATA_SCHEMA_SOURCE,
7784
config
7885
),
79-
errors=errors
86+
errors=errors,
87+
description_placeholders=description_placeholders
8088
)
8189

8290
@classmethod
@@ -112,7 +120,8 @@ async def async_step_user(
112120
DATA_SCHEMA_TARGET_TIME_PERIOD,
113121
user_input if user_input is not None else {}
114122
),
115-
errors=errors
123+
errors=errors,
124+
description_placeholders=description_placeholders
116125
)
117126

118127
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
@@ -132,7 +141,8 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None)
132141
DATA_SCHEMA_TARGET_TIME_PERIOD,
133142
config
134143
),
135-
errors=errors
144+
errors=errors,
145+
description_placeholders=description_placeholders
136146
)
137147

138148
class RollingTargetTimePeriodSubentryFlowHandler(ConfigSubentryFlow):
@@ -157,7 +167,8 @@ async def async_step_user(
157167
DATA_SCHEMA_ROLLING_TARGET_TIME_PERIOD,
158168
user_input if user_input is not None else {}
159169
),
160-
errors=errors
170+
errors=errors,
171+
description_placeholders=description_placeholders
161172
)
162173

163174
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
@@ -177,5 +188,6 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None)
177188
DATA_SCHEMA_ROLLING_TARGET_TIME_PERIOD,
178189
config
179190
),
180-
errors=errors
191+
errors=errors,
192+
description_placeholders=description_placeholders
181193
)

custom_components/target_timeframes/const.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,5 @@
185185
),
186186
})
187187

188-
EVENT_DATA_SOURCE = "target_time_period_data_source_updated"
188+
EVENT_DATA_SOURCE = "target_time_period_data_source_updated"
189+
EVENT_UPDATE_DATA_SOURCE = "target_timeframe_update_data_source"

custom_components/target_timeframes/entities/__init__.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def calculate_continuous_times(
141141
find_latest_values = False,
142142
min_value = None,
143143
max_value = None,
144-
weighting: list = None,
144+
weighting: str = None,
145145
hours_mode = CONFIG_TARGET_HOURS_MODE_EXACT,
146146
context: str = None
147147
):
@@ -151,9 +151,6 @@ def calculate_continuous_times(
151151
applicable_time_periods_count = len(applicable_time_periods)
152152
total_required_time_periods = math.ceil(target_hours * 2)
153153

154-
if weighting is not None and len(weighting) != total_required_time_periods:
155-
raise ValueError(f"{context} - Weighting does not match target hours")
156-
157154
best_continuous_time_periods = None
158155
best_continuous_time_periods_total = None
159156

@@ -169,8 +166,6 @@ def calculate_continuous_times(
169166
continue
170167

171168
continuous_time_periods = [time_period]
172-
value_weight = Decimal(time_period["weighting"]) if "weighting" in time_period else 1
173-
continuous_rates_total = Decimal(time_period["value"]) * value_weight * (weighting[0] if weighting is not None and len(weighting) > 0 else 1)
174169

175170
for offset in range(1, total_required_time_periods if hours_mode != CONFIG_TARGET_HOURS_MODE_MINIMUM else applicable_time_periods_count):
176171
if (index + offset) < applicable_time_periods_count:
@@ -183,21 +178,12 @@ def calculate_continuous_times(
183178
break
184179

185180
continuous_time_periods.append(offset_time_period)
186-
value_weight = Decimal(offset_time_period["weighting"]) if "weighting" in offset_time_period else 1
187-
continuous_rates_total += Decimal(offset_time_period["value"]) * value_weight * (weighting[offset] if weighting is not None else 1)
188181
else:
189182
break
190183

191184
current_continuous_time_periods_length = len(continuous_time_periods)
192185
best_continuous_time_periods_length = len(best_continuous_time_periods) if best_continuous_time_periods is not None else 0
193186

194-
is_best_continuous_rates = False
195-
if best_continuous_time_periods is not None:
196-
if search_for_highest_value:
197-
is_best_continuous_rates = (continuous_rates_total >= best_continuous_time_periods_total if find_latest_values else continuous_rates_total > best_continuous_time_periods_total)
198-
else:
199-
is_best_continuous_rates = (continuous_rates_total <= best_continuous_time_periods_total if find_latest_values else continuous_rates_total < best_continuous_time_periods_total)
200-
201187
has_required_hours = False
202188
if hours_mode == CONFIG_TARGET_HOURS_MODE_EXACT:
203189
has_required_hours = current_continuous_time_periods_length == total_required_time_periods
@@ -206,12 +192,26 @@ def calculate_continuous_times(
206192
elif hours_mode == CONFIG_TARGET_HOURS_MODE_MAXIMUM:
207193
has_required_hours = current_continuous_time_periods_length <= total_required_time_periods and current_continuous_time_periods_length >= best_continuous_time_periods_length
208194

209-
if ((best_continuous_time_periods is None or is_best_continuous_rates) and has_required_hours):
210-
best_continuous_time_periods = continuous_time_periods
211-
best_continuous_time_periods_total = continuous_rates_total
212-
_LOGGER.debug(f'{context} - New best block discovered {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None})')
213-
else:
214-
_LOGGER.debug(f'{context} - Total rates for current block {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None}). Total rates for best block {best_continuous_time_periods_total}')
195+
if has_required_hours:
196+
weighting_values = create_weighting(weighting, len(continuous_time_periods))
197+
if weighting_values is not None:
198+
continuous_rates_total = sum([Decimal(rate["value"]) * weighting_values[index] for index, rate in enumerate(continuous_time_periods)])
199+
else:
200+
continuous_rates_total = sum([Decimal(rate["value"]) for rate in continuous_time_periods])
201+
202+
is_best_continuous_rates = False
203+
if best_continuous_time_periods is not None:
204+
if search_for_highest_value:
205+
is_best_continuous_rates = (continuous_rates_total >= best_continuous_time_periods_total if find_latest_values else continuous_rates_total > best_continuous_time_periods_total)
206+
else:
207+
is_best_continuous_rates = (continuous_rates_total <= best_continuous_time_periods_total if find_latest_values else continuous_rates_total < best_continuous_time_periods_total)
208+
209+
if is_best_continuous_rates or best_continuous_time_periods is None:
210+
best_continuous_time_periods = continuous_time_periods
211+
best_continuous_time_periods_total = continuous_rates_total
212+
_LOGGER.debug(f'{context} - New best block discovered {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None})')
213+
else:
214+
_LOGGER.debug(f'{context} - Total rates for current block {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None}). Total rates for best block {best_continuous_time_periods_total}')
215215

216216
if best_continuous_time_periods is not None:
217217
# Make sure our rates are in ascending order before returning
@@ -409,6 +409,9 @@ def create_weighting(config: str, number_of_slots: int):
409409

410410
parts = config.split(',')
411411
parts_length = len(parts)
412+
if parts_length > number_of_slots:
413+
return None
414+
412415
weighting = []
413416
for index in range(parts_length):
414417
if (parts[index] == "*"):

custom_components/target_timeframes/entities/data_source.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from homeassistant.helpers.entity import generate_entity_id
1818

1919
from ..utils.attributes import dict_to_typed_dict
20-
from ..const import DOMAIN, EVENT_DATA_SOURCE
20+
from ..const import DOMAIN, EVENT_DATA_SOURCE, EVENT_UPDATE_DATA_SOURCE
2121
from ..storage.data_source_data import async_save_cached_data_source_data
2222
from ..utils.data_source_data import DataSourceItem, merge_data_source_data, validate_data_source_data
2323

@@ -67,6 +67,11 @@ def extra_state_attributes(self):
6767
@property
6868
def native_value(self):
6969
return self._state
70+
71+
@callback
72+
async def _async_handle_event(self, event) -> None:
73+
if event.data.get("data_source_id", '').lower() == self._source_id.lower():
74+
await self.async_update_target_timeframe_data_source(event.data.get("data", []))
7075

7176
async def async_added_to_hass(self):
7277
"""Call when entity about to be added to hass."""
@@ -81,6 +86,10 @@ async def async_added_to_hass(self):
8186

8287
_LOGGER.debug(f'Restored state: {self._state}')
8388

89+
self.async_on_remove(
90+
self._hass.bus.async_listen(EVENT_UPDATE_DATA_SOURCE, self._async_handle_event)
91+
)
92+
8493
@callback
8594
async def async_update_target_timeframe_data_source(self, data, replace_all_existing_data = False):
8695
"""Update target timeframe data source"""

0 commit comments

Comments
 (0)