Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a service to schedule based on an Octopus Target Rate time #47

Merged
merged 23 commits into from
Jun 29, 2024
Merged
Show file tree
Hide file tree
Changes from 20 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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,35 @@ Are fields related to the current, or most recent charging session.

This is a sensor of state class [total_increasing](https://developers.home-assistant.io/blog/2021/08/16/state_class_total/) which means that it is suitable for energy measurement within Home Assistant. Unlike `Hypervolt Session Energy`, the value is not taken directly from the Hypervolt APIs, cannot decrease during a session and will only reset on a new charging session, for which the [total_increasing](https://developers.home-assistant.io/blog/2021/08/16/state_class_total/) logic will handle. For a discussion of why this sensor was created, see [Sensor provides negative value when reset (HA Energy Dashboard) #5](https://github.com/gndean/home-assistant-hypervolt-charger/issues/5)

# Services

## set_schedule

The set_schedule service is intended to be used by 🐙 Octopus Agile users with the [Octopus Energy](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy) integration, specifically the [Target Rates](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/setup/target_rate/) sensors which allow you to find the cheapest periods between two times. This allows an Agile user to set the schedule on the Hypervolt rather than switching the Hypervolt on or off based on the `binary_sensor`. This hopefully avoids failed charges due to cloud or connectivity outages that may occur overnight, and allows the user to check the schedule before settling down for bed 😴. A pseudo-intelligent automation could trigger when the car is plugged in (e.g. from a car integration), set a [dynamic target rate](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/services/#octopus_energyupdate_target_config) between the current time until the morning, schedule the Hypervolt for those periods, and send a notification to the phone with the intended charging periods.

It has one target and 4 parameters:

* Target Device - Your Hypervolt ID
* Tracker Rate - The binary_sensor target created by the Octopus Energy integration
* Backup Start - A backup start period if no times are found in the target sensor, consider setting this to give you enough charge to get to work and back...
* Backup End - Backup end period
* Append Backup - If checked this will always append the backup schedule period to the schedule. For example, we could set 06:00-07:00 to allow morning pre-heat climate control to draw from the grid rather than battery

The following sample yaml can be used to configure the service in an automation:

```yaml
service: hypervolt_charger.set_schedule
data:
backup_schedule_start: "04:30:00"
backup_schedule_end: "06:00:00"
tracker_rate: binary_sensor.octopus_energy_target_test
append_backup: true
target:
device_id: HYPERVOLT_DEVICE_ID
```

It also has code to support an [intelligent_dispatching](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/intelligent/) sensor to schedule the Hypervolt to match the `planned_dispatches` time periods. This has not been tested yet, but could be useful for owners with two cars and two chargers that want to set the second car to match the first car that is on the Intelligent Octopus Go tariff, or potentially Intelligent Octopus Flux or any other Intelligent stuff Octopus comes out with.

# Known limitations

- Tested with version 2.0 and 3.0 Hypervolt home charge points. `Hypervolt Voltage` is not supported on 3.0 charge points.
Expand Down
7 changes: 6 additions & 1 deletion custom_components/hypervolt_charger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from .hypervolt_update_coordinator import HypervoltUpdateCoordinator
from .utils import get_version_from_manifest

from .service import async_setup_services

# There should be a file for each of the declared platforms e.g. sensor.py
PLATFORMS: list[Platform] = [
Platform.SENSOR,
Expand All @@ -31,6 +33,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_LOGGER.debug("Async_setup enter")

hass.data.setdefault(DOMAIN, {})

# Services
await async_setup_services(hass)

return True


Expand Down Expand Up @@ -70,7 +76,6 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool:

raise ConfigEntryNotReady from exc


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.debug("Async_unload_entry enter")
Expand Down
151 changes: 151 additions & 0 deletions custom_components/hypervolt_charger/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@

from __future__ import annotations

import logging
import time as __time

from homeassistant.config_entries import ConfigEntry, ConfigType
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry
from homeassistant.util.dt import (now, get_time_zone, parse_time)

from .const import DOMAIN
from .hypervolt_update_coordinator import HypervoltUpdateCoordinator
from .hypervolt_device_state import (
HypervoltActivationMode,
HypervoltScheduleInterval,
)

_LOGGER = logging.getLogger(__name__)

async def async_setup_services(hass: HomeAssistant) -> None:
"""Service handler setup."""
_LOGGER.debug("Setting up schedule service")

async def async_set_schedule(service: ha.ServiceCall) -> None:
Meatballs1 marked this conversation as resolved.
Show resolved Hide resolved
_LOGGER.info(f"Setting schedule intervals")

dev_reg = device_registry.async_get(hass)
tracker_id = service.data.get('tracker_rate', None)
backup_start = service.data.get('backup_schedule_start', None)
backup_end = service.data.get('backup_schedule_end', None)
append = service.data.get('append_backup', False)
scheduled_blocks = None
timezone = None

backup_available = (backup_start is not None and backup_end is not None)
if backup_available:
backup_start = parse_time(backup_start)
backup_end = parse_time(backup_end)
_LOGGER.debug(f"Backup times: {backup_start} -> {backup_end}")

if append and not backup_available:
_LOGGER.warning("Requested backup schedule appended but not provided!")

for device_id in service.data.get('device_id', None):
device = dev_reg.async_get(device_id)

if device is not None:
for config_id in device.config_entries:
coordinator: HypervoltUpdateCoordinator = hass.data[DOMAIN].get(config_id, None)
if coordinator is None:
_LOGGER.debug(f"Unknown config_id {config_id}")
else:
timezone = get_time_zone(coordinator.data.schedule_tz)
Meatballs1 marked this conversation as resolved.
Show resolved Hide resolved
break

else:
_LOGGER.warning(f"Unknown device id, unable to set schedule: {device_id}")
return

if tracker_id is not None:
tracker = hass.states.get(tracker_id)

if tracker is not None:
if tracker.attributes.get("last_evaluated", None) is None:
_LOGGER.info("Tracker not evaluated yet")
if backup_available is False:
_LOGGER.warning("Tracker data not available and no backup set, unable to set schedule")
return

if tracker.attributes.get("planned_dispatches", None) is not None:
# Using intelligent tracker
scheduled_blocks = tracker.attributes.get("planned_dispatches")
else:
if tracker.attributes.get("rates_incomplete"):
_LOGGER.info("Tracker rates not available yet")
else:
scheduled_blocks = tracker.attributes.get("target_times", None)

_LOGGER.debug(f"Current time: {now()}")
_LOGGER.debug(f"Current time in HV tz: {now(timezone)}")

intervals = []
merged_intervals = []
if scheduled_blocks is not None:

_LOGGER.info(f"Scheduled blocks from tracker: {scheduled_blocks}")
for block in scheduled_blocks:
# Only append blocks that haven't already finished. Backup will be appended
# regardless. Modify timezone to match the existing scheduler tz data
if now() < block["end"].astimezone(timezone):
_LOGGER.debug(f"Start: {block['start']}")
start = block["start"].astimezone(timezone)
_LOGGER.debug(f"Start tz adjusted: {start}")
end = block["end"].astimezone(timezone)
interval = HypervoltScheduleInterval(start.time(), end.time())
intervals.append(interval)

_LOGGER.info(f"Intervals to set:")
for interval in intervals:
_LOGGER.info(f"{interval.start_time} -> {interval.end_time}")

_LOGGER.info("Merging continuous times:")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annoyingly, the HV 3.0 charger does not allow times to span midnight. If trying to set that, the API returns an error like:
"schedule.set failed: assertion failed: Session startTime (HoursMinutes(12,0)) must be < endTime (HoursMinutes(3,30))"}}
When trying to do this in the HV app, two sessions are created:
startTime -> 24:00
00:00 -> endTime
So I think there needs to be logic for this too 😢

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might have to do something like this for the merging and splitting, then I put a kludge in the API client code that converts 23:59:59 into 24:00 when it's passed to the HV API.

            _LOGGER.info("Merging continuous times:")

            if intervals:
                merged_intervals.append(intervals[0])
            for interval in intervals[1:]:
                last_interval = merged_intervals[-1]
                if last_interval.end_time == interval.start_time:
                    last_interval.end_time = interval.end_time
                else:
                    merged_intervals.append(interval)

            # Break up any interval spanning midnight
            for interval in merged_intervals.copy():
                if interval.start_time > interval.end_time:
                    _LOGGER.info(
                        f"Splitting interval {interval.start_time} -> {interval.end_time}"
                    )
                    end = interval.end_time
                    interval.end_time = datetime.time(hour=23, minute=59, second=59)
                    merged_intervals.append(
                        HypervoltScheduleInterval(datetime.time(hour=0, minute=0), end)
                    )

            for interval in merged_intervals:
                _LOGGER.info(f"{interval.start_time} -> {interval.end_time}")```

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, re-thinking this. The splitting should probably happen with the hypervolt_api_client code, not in here. So then it works for anything that calls set_schedule.

So I've changed my mind - don't split intervals spanning midnight here.

i = 0
while i < len(intervals):
time_a = intervals[i]
skip = 1
while (i+skip) < len(intervals):
if time_a.end_time == intervals[i+skip].start_time:
time_a.end_time = intervals[i+skip].end_time
skip = skip + 1
else:
break
i = i + skip
merged_intervals.append(time_a)

for interval in merged_intervals:
_LOGGER.info(f"{interval.start_time} -> {interval.end_time}")

# Hypervolt will apply a schedule even if it overlaps existing schedules but
# have not tested how this is interpreted by the charger in reality
if append and backup_available:
_LOGGER.info(f"Appending backup {backup_start} -> {backup_end}")
merged_intervals.append(HypervoltScheduleInterval(backup_start, backup_end))

else:
if backup_available:
_LOGGER.info(f"No scheduled blocks found, using backup {backup_start} - {backup_end}")
intervals.append(HypervoltScheduleInterval(backup_start, backup_end))
Meatballs1 marked this conversation as resolved.
Show resolved Hide resolved

_LOGGER.debug(f"Timezone used: {timezone}")

await coordinator.api.set_schedule(
coordinator.api_session,
HypervoltActivationMode.SCHEDULE,
merged_intervals,
"restricted", #coordinator.data.schedule_type,
coordinator.data.schedule_tz
)

_LOGGER.info("Schedule applied. Reading back from API")

# Read back schedule from API so that we're synced with the API
await coordinator.force_update()

_LOGGER.info("Read back shedule intervals")

# Register the service
hass.services.async_register(DOMAIN, "set_schedule", async_set_schedule)
26 changes: 26 additions & 0 deletions custom_components/hypervolt_charger/services.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
set_schedule:
target:
device:
integration: hypervolt_charger
fields:
tracker_rate:
required: true
description: Target tracker rate to schedule
selector:
entity:
integration: octopus_energy
domain: binary_sensor
backup_schedule_start:
description: Backup start time if time retrieval fails
selector:
time:
backup_schedule_end:
description: Backup end time if start time retrieval fails
selector:
time:
append_backup:
required: true
description: Append the backup period to the schedule
selector:
boolean: