Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion _docs/events.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
# Events

The following events are raised by the integration. These events power various entities and can also be used within automations.
The following events are either raised or received by the integration. These events power various entities and can also be used within automations.

## Update Data Source

`target_timeframe_update_data_source`

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.

| Attribute | Type | Description |
|-----------|------|-------------|
| `data_source_id` | `string` | The id of the data source the data belongs to |
| `data` | `array` | The data to update the data source with |

For each item in `data`, the following attributes should be present

| Attribute | Type | Description |
|-----------|------|-------------|
| `start` | `datetime` | The start timestamp the value is effective from |
| `end` | `datetime` | The end timestamp the value is effective to |
| `value` | `float` | The value that is applicable for the timeframe. This could be something like an electricity rate |
| `metadata` | `object` | Additional metadata that might describe how the value was created |
8 changes: 6 additions & 2 deletions _docs/setup/data_source.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ The name of the data source. This is for informative purposes.

### Id

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`.
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.

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.
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.

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.

## Entities

Once the data source has been created, you'll have the following entities created.

### Data Source Last Updated

`sensor.target_timeframes_{{DATA_SOURCE_ID}}_data_source_last_updated`
Expand Down
2 changes: 1 addition & 1 deletion _docs/setup/rolling_target_timeframe.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ There may be times that you want the target timeframe sensors to not take into a

!!! info

This is only available for **continuous** target value sensors in **exact** hours mode.
This is only available for **continuous** target value sensors.

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.

Expand Down
2 changes: 1 addition & 1 deletion _docs/setup/target_timeframe.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ There may be times that you want the target timeframe sensors to not take into a

!!! info

This is only available for **continuous** target value sensors in **exact** hours mode.
This is only available for **continuous** target value sensors.

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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
CONFIG_TARGET_HOURS,
CONFIG_TARGET_HOURS_MODE,
CONFIG_TARGET_HOURS_MODE_EXACT,
CONFIG_TARGET_HOURS_MODE_MAXIMUM,
CONFIG_TARGET_HOURS_MODE_MINIMUM,
CONFIG_TARGET_MAX_VALUE,
CONFIG_TARGET_MIN_VALUE,
Expand Down Expand Up @@ -120,14 +121,19 @@ def validate_rolling_target_timeframe_config(data):
number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2)
weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots)

if (len(weighting) != number_of_slots):
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
if (weighting is None or len(weighting) != number_of_slots):
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
errors[CONFIG_TARGET_WEIGHTING] = "invalid_minimum_weighting_slots"
elif CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MAXIMUM:
errors[CONFIG_TARGET_WEIGHTING] = "invalid_maximum_weighting_slots"
else:
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"

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]:
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_varied_for_hour_mode"

if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS:
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_type"

if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT:
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_hour_mode"

if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
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):
Expand Down
16 changes: 11 additions & 5 deletions custom_components/target_timeframes/config/target_timeframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CONFIG_TARGET_HOURS,
CONFIG_TARGET_HOURS_MODE,
CONFIG_TARGET_HOURS_MODE_EXACT,
CONFIG_TARGET_HOURS_MODE_MAXIMUM,
CONFIG_TARGET_HOURS_MODE_MINIMUM,
CONFIG_TARGET_MAX_VALUE,
CONFIG_TARGET_MIN_VALUE,
Expand Down Expand Up @@ -146,14 +147,19 @@ def validate_target_timeframe_config(data):
number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2)
weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots)

if (len(weighting) != number_of_slots):
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
if (weighting is None or len(weighting) != number_of_slots):
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
errors[CONFIG_TARGET_WEIGHTING] = "invalid_minimum_weighting_slots"
elif CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MAXIMUM:
errors[CONFIG_TARGET_WEIGHTING] = "invalid_maximum_weighting_slots"
else:
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"

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]:
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_varied_for_hour_mode"

if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS:
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_type"

if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT:
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_hour_mode"

if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
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):
Expand Down
24 changes: 18 additions & 6 deletions custom_components/target_timeframes/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@

from .config.data_source import validate_source_config

description_placeholders = {
"setup_data_source_docs_url": "https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/data_source",
"setup_target_timeframe_docs_url": "https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/target_timeframe",
"setup_rolling_target_timeframe_docs_url": "https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/rolling_target_timeframe",
}

class TargetTimeframesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow."""

Expand All @@ -48,7 +54,8 @@ async def async_step_user(self, user_input):
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA_SOURCE,
errors=errors
errors=errors,
description_placeholders=description_placeholders
)

async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
Expand Down Expand Up @@ -76,7 +83,8 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None)
DATA_SCHEMA_SOURCE,
config
),
errors=errors
errors=errors,
description_placeholders=description_placeholders
)

@classmethod
Expand Down Expand Up @@ -112,7 +120,8 @@ async def async_step_user(
DATA_SCHEMA_TARGET_TIME_PERIOD,
user_input if user_input is not None else {}
),
errors=errors
errors=errors,
description_placeholders=description_placeholders
)

async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
Expand All @@ -132,7 +141,8 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None)
DATA_SCHEMA_TARGET_TIME_PERIOD,
config
),
errors=errors
errors=errors,
description_placeholders=description_placeholders
)

class RollingTargetTimePeriodSubentryFlowHandler(ConfigSubentryFlow):
Expand All @@ -157,7 +167,8 @@ async def async_step_user(
DATA_SCHEMA_ROLLING_TARGET_TIME_PERIOD,
user_input if user_input is not None else {}
),
errors=errors
errors=errors,
description_placeholders=description_placeholders
)

async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
Expand All @@ -177,5 +188,6 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None)
DATA_SCHEMA_ROLLING_TARGET_TIME_PERIOD,
config
),
errors=errors
errors=errors,
description_placeholders=description_placeholders
)
3 changes: 2 additions & 1 deletion custom_components/target_timeframes/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,5 @@
),
})

EVENT_DATA_SOURCE = "target_time_period_data_source_updated"
EVENT_DATA_SOURCE = "target_time_period_data_source_updated"
EVENT_UPDATE_DATA_SOURCE = "target_timeframe_update_data_source"
45 changes: 24 additions & 21 deletions custom_components/target_timeframes/entities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def calculate_continuous_times(
find_latest_values = False,
min_value = None,
max_value = None,
weighting: list = None,
weighting: str = None,
hours_mode = CONFIG_TARGET_HOURS_MODE_EXACT,
context: str = None
):
Expand All @@ -151,9 +151,6 @@ def calculate_continuous_times(
applicable_time_periods_count = len(applicable_time_periods)
total_required_time_periods = math.ceil(target_hours * 2)

if weighting is not None and len(weighting) != total_required_time_periods:
raise ValueError(f"{context} - Weighting does not match target hours")

best_continuous_time_periods = None
best_continuous_time_periods_total = None

Expand All @@ -169,8 +166,6 @@ def calculate_continuous_times(
continue

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

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

continuous_time_periods.append(offset_time_period)
value_weight = Decimal(offset_time_period["weighting"]) if "weighting" in offset_time_period else 1
continuous_rates_total += Decimal(offset_time_period["value"]) * value_weight * (weighting[offset] if weighting is not None else 1)
else:
break

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

is_best_continuous_rates = False
if best_continuous_time_periods is not None:
if search_for_highest_value:
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)
else:
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)

has_required_hours = False
if hours_mode == CONFIG_TARGET_HOURS_MODE_EXACT:
has_required_hours = current_continuous_time_periods_length == total_required_time_periods
Expand All @@ -206,12 +192,26 @@ def calculate_continuous_times(
elif hours_mode == CONFIG_TARGET_HOURS_MODE_MAXIMUM:
has_required_hours = current_continuous_time_periods_length <= total_required_time_periods and current_continuous_time_periods_length >= best_continuous_time_periods_length

if ((best_continuous_time_periods is None or is_best_continuous_rates) and has_required_hours):
best_continuous_time_periods = continuous_time_periods
best_continuous_time_periods_total = continuous_rates_total
_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})')
else:
_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}')
if has_required_hours:
weighting_values = create_weighting(weighting, len(continuous_time_periods))
if weighting_values is not None:
continuous_rates_total = sum([Decimal(rate["value"]) * weighting_values[index] for index, rate in enumerate(continuous_time_periods)])
else:
continuous_rates_total = sum([Decimal(rate["value"]) for rate in continuous_time_periods])

is_best_continuous_rates = False
if best_continuous_time_periods is not None:
if search_for_highest_value:
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)
else:
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)

if is_best_continuous_rates or best_continuous_time_periods is None:
best_continuous_time_periods = continuous_time_periods
best_continuous_time_periods_total = continuous_rates_total
_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})')
else:
_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}')

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

parts = config.split(',')
parts_length = len(parts)
if parts_length > number_of_slots:
return None

weighting = []
for index in range(parts_length):
if (parts[index] == "*"):
Expand Down
11 changes: 10 additions & 1 deletion custom_components/target_timeframes/entities/data_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from homeassistant.helpers.entity import generate_entity_id

from ..utils.attributes import dict_to_typed_dict
from ..const import DOMAIN, EVENT_DATA_SOURCE
from ..const import DOMAIN, EVENT_DATA_SOURCE, EVENT_UPDATE_DATA_SOURCE
from ..storage.data_source_data import async_save_cached_data_source_data
from ..utils.data_source_data import DataSourceItem, merge_data_source_data, validate_data_source_data

Expand Down Expand Up @@ -67,6 +67,11 @@ def extra_state_attributes(self):
@property
def native_value(self):
return self._state

@callback
async def _async_handle_event(self, event) -> None:
if event.data.get("data_source_id", '').lower() == self._source_id.lower():
await self.async_update_target_timeframe_data_source(event.data.get("data", []))

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

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

self.async_on_remove(
self._hass.bus.async_listen(EVENT_UPDATE_DATA_SOURCE, self._async_handle_event)
)

@callback
async def async_update_target_timeframe_data_source(self, data, replace_all_existing_data = False):
"""Update target timeframe data source"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,7 @@ async def async_update(self):
)

if applicable_time_periods is not None:
number_of_slots = math.ceil(target_hours * 2)
weighting = create_weighting(self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None, number_of_slots)
weighting = self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None

if (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_CONTINUOUS):
self._target_timeframes = calculate_continuous_times(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,7 @@ async def async_update(self):
)

if applicable_time_periods is not None and is_target_timeframe_complete == False:
number_of_slots = math.ceil(target_hours * 2)
weighting = create_weighting(self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None, number_of_slots)
weighting = self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None

proposed_target_timeframes = None
if (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_CONTINUOUS):
Expand Down
Loading
Loading