diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 1b54b97c7..7ffcca9af 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -107,6 +107,11 @@ "required": True, "config": "octopus_api_account", }, + "automatic": { + "required": False, + "default": True, + "config": "octopus_automatic", + }, }, }, "ohme": { diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index cdeecd6b8..2ffbfe759 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1061,18 +1061,10 @@ def fetch_sensor_data(self): # Log current values self.log("Current data so far today: load {} kWh import {} kWh export {} kWh pv {} kWh".format(dp2(self.load_minutes_now), dp2(self.import_today_now), dp2(self.export_today_now), dp2(self.pv_today_now))) - octopus_api_direct = self.components.get_component("octopus") if "rates_import_octopus_url" in self.args: # Fixed URL for rate import self.log("Downloading import rates directly from URL {}".format(self.get_arg("rates_import_octopus_url", indirect=False))) self.rate_import = self.download_octopus_rates(self.get_arg("rates_import_octopus_url", indirect=False)) - elif octopus_api_direct: - self.log("Downloading import rates directly from Octopus API") - self.rate_import = self.get_octopus_direct("import") - if not self.rate_import: - self.log("Error: Unable to download rates from Octopus API") - self.record_status(message="Error: Unable to download rates from Octopus API", had_errors=True) - raise ValueError elif "metric_octopus_import" in self.args: # Octopus import rates entity_id = self.get_arg("metric_octopus_import", None, indirect=False) @@ -1094,12 +1086,7 @@ def fetch_sensor_data(self): self.rate_import = self.basic_rates(self.get_arg("rates_import", [], indirect=False), "rates_import") # Gas rates if set - if octopus_api_direct: - self.log("Downloading gas rates directly from Octopus API") - self.rate_gas = self.get_octopus_direct("gas") - self.rate_gas, self.rate_gas_replicated = self.rate_replicate(self.rate_gas, is_import=False, is_gas=False) - self.rate_scan_gas(self.rate_gas, print=True) - elif "metric_octopus_gas" in self.args: + if "metric_octopus_gas" in self.args: entity_id = self.get_arg("metric_octopus_gas", None, indirect=False) self.rate_gas = self.fetch_octopus_rates(entity_id) if not self.rate_gas: @@ -1138,40 +1125,28 @@ def fetch_sensor_data(self): # Work out current car SoC and limit self.car_charging_loss = 1 - float(self.get_arg("car_charging_loss")) - slot_is_configured = "octopus_intelligent_slot" in self.args + entity_id = self.get_arg("octopus_intelligent_slot", indirect=False) ohme_automatic = self.get_arg("ohme_automatic", False) - if (octopus_api_direct and octopus_api_direct.get_intelligent_device()) or (not octopus_api_direct and slot_is_configured): + if entity_id: completed = [] planned = [] - vehicle = {} - vehicle_pref = {} - - if octopus_api_direct and not ohme_automatic: - completed = octopus_api_direct.get_intelligent_completed_dispatches() - planned = octopus_api_direct.get_intelligent_planned_dispatches() - vehicle = octopus_api_direct.get_intelligent_vehicle() - vehicle_pref = vehicle - self.log("Octopus API planned and completed slots: {} - {}".format(completed, planned)) - else: - entity_id = self.get_arg("octopus_intelligent_slot", indirect=False) - if entity_id and "octopus_intelligent_slot_action_config" in self.args: - config_entry = self.get_arg("octopus_intelligent_slot_action_config", None, indirect=False) - service_name = entity_id.replace(".", "/") - result = self.call_service_wrapper(service_name, config_entry=config_entry, return_response=True) - if result and ("slots" in result): - planned = result["slots"] - else: - self.log("Warn: Unable to get data from {} - octopus_intelligent_slot using action config {} result was {}".format(entity_id, config_entry, result)) + + if entity_id and "octopus_intelligent_slot_action_config" in self.args: + config_entry = self.get_arg("octopus_intelligent_slot_action_config", None, indirect=False) + service_name = entity_id.replace(".", "/") + result = self.call_service_wrapper(service_name, config_entry=config_entry, return_response=True) + if result and ("slots" in result): + planned = result["slots"] else: - try: - completed = self.get_state_wrapper(entity_id=entity_id, attribute="completedDispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="completed_dispatches") - planned = self.get_state_wrapper(entity_id=entity_id, attribute="plannedDispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="planned_dispatches") - vehicle = self.get_state_wrapper(entity_id=entity_id, attribute="registeredKrakenflexDevice") - vehicle_pref = self.get_state_wrapper(entity_id=entity_id, attribute="vehicleChargingPreferences") - except (ValueError, TypeError): - self.log("Warn: Unable to get data from {} - octopus_intelligent_slot may not be set correctly".format(entity_id)) - self.record_status(message="Error: octopus_intelligent_slot not set correctly", had_errors=True) + self.log("Warn: Unable to get data from {} - octopus_intelligent_slot using action config {} result was {}".format(entity_id, config_entry, result)) + else: + try: + completed = self.get_state_wrapper(entity_id=entity_id, attribute="completed_dispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="completedDispatches") + planned = self.get_state_wrapper(entity_id=entity_id, attribute="planned_dispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="plannedDispatches") + except (ValueError, TypeError): + self.log("Warn: Unable to get data from {} - octopus_intelligent_slot may not be set correctly".format(entity_id)) + self.record_status(message="Error: octopus_intelligent_slot not set correctly", had_errors=True) # Completed and planned slots if completed: @@ -1186,33 +1161,18 @@ def fetch_sensor_data(self): if self.num_cars >= 1: # Extract vehicle data if we can get it - if vehicle or (octopus_api_direct and not ohme_automatic): - self.car_charging_battery_size[0] = float(vehicle.get("vehicleBatterySizeInKwh", self.car_charging_battery_size[0])) - self.car_charging_rate[0] = float(vehicle.get("chargePointPowerInKw", self.car_charging_rate[0])) - else: - size = self.get_state_wrapper(entity_id=entity_id, attribute="vehicle_battery_size_in_kwh") - rate = self.get_state_wrapper(entity_id=entity_id, attribute="charge_point_power_in_kw") - if size: - self.car_charging_battery_size[0] = size - if rate: - self.car_charging_rate[0] = rate + size = self.get_state_wrapper(entity_id=entity_id, attribute="vehicle_battery_size_in_kwh") + rate = self.get_state_wrapper(entity_id=entity_id, attribute="charge_point_power_in_kw") + if size: + self.car_charging_battery_size[0] = size + if rate: + self.car_charging_rate[0] = rate # Get car charging limit again from car based on new battery size self.car_charging_limit[0] = dp3((float(self.get_arg("car_charging_limit", 100.0, index=0)) * self.car_charging_battery_size[0]) / 100.0) # Extract vehicle preference if we can get it - if (vehicle_pref or (octopus_api_direct and not ohme_automatic)) and self.octopus_intelligent_charging: - octopus_limit = max(float(vehicle_pref.get("weekdayTargetSoc", 100)), float(vehicle_pref.get("weekendTargetSoc", 100))) - octopus_ready_time = vehicle_pref.get("weekdayTargetTime", None) - if not octopus_ready_time: - octopus_ready_time = self.car_charging_plan_time[0] - else: - if isinstance(octopus_ready_time, str) and len(octopus_ready_time) == 5: - octopus_ready_time += ":00" - self.car_charging_plan_time[0] = octopus_ready_time - octopus_limit = dp3(octopus_limit * self.car_charging_battery_size[0] / 100.0) - self.car_charging_limit[0] = min(self.car_charging_limit[0], octopus_limit) - elif self.octopus_intelligent_charging: + if self.octopus_intelligent_charging: octopus_ready_time = self.get_arg("octopus_ready_time", None) if isinstance(octopus_ready_time, str) and len(octopus_ready_time) == 5: octopus_ready_time += ":00" @@ -1229,8 +1189,7 @@ def fetch_sensor_data(self): if octopus_ready_time: self.car_charging_plan_time[0] = octopus_ready_time - # Use octopus slots for charging? - if self.octopus_intelligent_charging: + # Use octopus slots for charging? self.octopus_slots = self.add_now_to_octopus_slot(self.octopus_slots, self.now_utc) if not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[0]: self.car_charging_slots[0] = self.load_octopus_slots(self.octopus_slots, self.octopus_intelligent_consider_full) @@ -1262,9 +1221,6 @@ def fetch_sensor_data(self): # Fixed URL for rate export self.log("Downloading export rates directly from URL {}".format(self.get_arg("rates_export_octopus_url", indirect=False))) self.rate_export = self.download_octopus_rates(self.get_arg("rates_export_octopus_url", indirect=False)) - elif octopus_api_direct: - self.log("Downloading export rates directly from Octopus API") - self.rate_export = self.get_octopus_direct("export") elif "metric_octopus_export" in self.args: # Octopus export rates entity_id = self.get_arg("metric_octopus_export", None, indirect=False) @@ -1287,12 +1243,8 @@ def fetch_sensor_data(self): self.octopus_free_slots, self.octopus_saving_slots = self.fetch_octopus_sessions() # Standing charge - if octopus_api_direct: - self.metric_standing_charge = self.get_standing_charge_direct() - self.log("Octopus Import standing charge is set to {} p".format(self.metric_standing_charge)) - else: - self.metric_standing_charge = self.get_arg("metric_standing_charge", 0.0) * 100.0 - self.log("Standing charge is set to {} p".format(self.metric_standing_charge)) + self.metric_standing_charge = self.get_arg("metric_standing_charge", 0.0) * 100.0 + self.log("Standing charge is set to {} p".format(self.metric_standing_charge)) # futurerate data futurerate = FutureRate(self) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 2659dcb34..66f9d24e1 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -37,6 +37,9 @@ OCTOPUS_DAY_RATE_END_HOUR = 0 OCTOPUS_DAY_RATE_END_MINUTE = 30 +BASE_TIME = datetime.strptime("00:00", "%H:%M") +OPTIONS_TIME = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M")) for minute in range(4 * 60, 11 * 60, 30)] + def is_active(now_utc, activeFrom, activeTo): if not activeFrom: @@ -267,6 +270,23 @@ def parse_date_time(dt_str): }} """ +intelligent_settings_mutation = """mutation {{ + setDevicePreferences(input: {{ + deviceId: "{device_id}" + mode: CHARGE + unit: PERCENTAGE + schedules: [{schedules}] + }}) {{ + id + }} +}}""" + +intelligent_settings_mutation_schedule = """{{ + dayOfWeek: {day_of_week} + time: "{target_time}" + max: {target_percentage} +}}""" + class OctopusEnergyApiClient: def __init__(self, api_key, log, timeout_in_seconds=20): @@ -296,7 +316,7 @@ async def async_create_client_session(self): class OctopusAPI: - def __init__(self, api_key, account_id, base): + def __init__(self, api_key, account_id, automatic, base): self.api_key = api_key self.base = base self.log = base.log @@ -315,6 +335,8 @@ def __init__(self, api_key, account_id, base): self.intelligent_device = {} self.api_started = False self.cache_path = self.base.config_root + "/cache" + self.automatic = automatic + self.commands = [] # API request metrics for monitoring self.requests_total = 0 @@ -325,10 +347,20 @@ def __init__(self, api_key, account_id, base): self.cache_file = self.cache_path + "/octopus.yaml" async def select_event(self, entity_id, value): - pass + if entity_id == self.get_entity_name("select", "intelligent_target_time"): + self.commands.append({"command": "set_intelligent_target_time", "value": value}) + elif entity_id == self.get_entity_name("select", "saving_session_join"): + self.commands.append({"command": "join_saving_session_event", "event_code": value}) async def number_event(self, entity_id, value): - pass + if entity_id == self.get_entity_name("number", "intelligent_target_soc"): + # Set the target soc + try: + value = int(value) + except ValueError: + self.log("Error: Invalid value for intelligent target soc: {}".format(value)) + return + self.commands.append({"command": "set_intelligent_target_percentage", "value": value}) async def switch_event(self, entity_id, service): pass @@ -382,27 +414,57 @@ async def start(self): await self.async_update_intelligent_device(self.account_id) await self.fetch_tariffs(self.tariffs) self.saving_sessions = await self.async_get_saving_sessions(self.account_id) + self.get_saving_session_data() if first or (count_minutes % 2) == 0: # 2-minute update for intelligent device sensor await self.async_intelligent_update_sensor(self.account_id) await self.save_octopus_cache() - await self.async_join_saving_session_events(self.account_id) + first = False + + # Process any queued commands + if await self.process_commands(self.account_id): + # Trigger a refresh + first = True if not self.api_started: + if self.automatic: + self.automatic_config(self.tariffs) print("Octopus API: Started") self.api_started = True - first = False except Exception as e: self.log("Error: Octopus API: {}".format(e)) self.log("Error: " + traceback.format_exc()) - await asyncio.sleep(60) + await asyncio.sleep(10) await self.api.async_close() print("Octopus API: Stopped") + async def process_commands(self, account_id): + """ + Process queued commands + """ + commands = self.commands[:] + self.commands = [] + done_command = False + for command in commands: + command_name = command.get("command", "") + if command_name == "set_intelligent_target_percentage": + value = command.get("value", None) + await self.async_set_intelligent_target_schedule(account_id, target_percentage=int(value)) + done_command = True + elif command_name == "set_intelligent_target_time": + value = command.get("value", None) + await self.async_set_intelligent_target_schedule(account_id, target_time=value) + done_command = True + elif command_name == "join_saving_session_event": + event_code = command.get("event_code", None) + await self.async_join_saving_session_events(self.account_id, event_code) + done_command = True + return done_command + async def load_octopus_cache(self): """ Load the octopus cache @@ -529,22 +591,45 @@ def join_saving_session_event(self, event_code): """ Join a saving session event """ - self.saving_sessions_to_join.append(event_code) + self.commands.append({"command": "join_saving_session_event", "event_code": event_code}) - async def async_join_saving_session_events(self, account_id): + async def async_set_intelligent_target_schedule(self, account_id, target_percentage=None, target_time=None): + """ + Set the intelligent target schedule + """ + device = self.get_intelligent_device() + if not device: + self.log("Warn: Octopus API: Try to set target schedule, but no intelligent device found") + return + device_id = device.get("device_id", None) + if not device_id: + self.log("Warn: Octopus API: Try to set target schedule, but no intelligent device ID found") + return + daysOfWeek = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] + if target_time is None: + target_time = self.get_intelligent_target_time() + target_time = target_time[:5] # HH:MM format + if target_percentage is None: + target_percentage = self.get_intelligent_target_soc() + self.log("Octopus API: Setting intelligent target schedule time {} percentage {}".format(target_time, target_percentage)) + schedule = ", ".join(list(map(lambda day: intelligent_settings_mutation_schedule.format(day_of_week=day, target_percentage=target_percentage, target_time=target_time), daysOfWeek))) + await self.async_graphql_query(intelligent_settings_mutation.format(device_id=device_id, schedules=schedule), "set-intelligent-target-time", returns_data=False) + + # Update cached data + device["weekend_target_time"] = target_time + device["weekend_target_soc"] = target_percentage + device["weekday_target_time"] = target_time + device["weekday_target_soc"] = target_percentage + + async def async_join_saving_session_events(self, account_id, event_code): """ Join the saving session events """ - sessions_to_join = self.saving_sessions_to_join - self.saving_sessions_to_join = [] - - # Join the saving sessions - for event_code in sessions_to_join: + if event_code: + # Join the saving sessions self.log("Octopus API: Joining saving session event {}".format(event_code)) await self.async_graphql_query(octoplus_saving_session_join_mutation.format(account_id=account_id, event_code=event_code), "join-saving-session-event", returns_data=False) - - # Re-fetch the saving sessions if we have joined any - if sessions_to_join: + # Re-fetch the saving sessions if we have joined any self.saving_sessions = await self.async_get_saving_sessions(account_id) def get_intelligent_device(self): @@ -614,7 +699,8 @@ def get_intelligent_target_time(self): """ devices = self.get_intelligent_device() if devices: - return devices.get("weekday_target_time", None) + is_weekend = self.now_utc.weekday() >= 5 + return devices.get("weekday_target_time" if not is_weekend else "weekend_target_time", None) else: return None @@ -624,10 +710,19 @@ def get_intelligent_target_soc(self): """ devices = self.get_intelligent_device() if devices: - return devices.get("weekday_target_soc", None) + is_weekend = self.now_utc.weekday() >= 5 + return devices.get("weekday_target_soc" if not is_weekend else "weekend_target_soc", None) else: return None + def get_entity_name(self, root, suffix): + """ + Get the entity name + """ + entity_name = root + ".predbat_octopus_" + self.account_id.replace("-", "_") + "_" + suffix + entity_name = entity_name.lower() + return entity_name + def get_saving_session_data(self): """ Get the saving sessions data @@ -665,8 +760,6 @@ def get_saving_session_data(self): event_id = event.get("eventId", None) if start and end: return_joined_events.append({"start": start, "end": end, "octopoints_per_kwh": event_reward.get(event_id, None), "rewarded_octopoints": event.get("rewardGivenInOctoPoints", None), "id": event_id, "code": event_code.get(event_id, None)}) - entity_name = "binary_sensor.predbat_octopus_" + self.account_id.replace("-", "_") - entity_name = entity_name.lower() saving_attributes = {"friendly_name": "Octopus Intelligent Saving Sessions", "icon": "mdi:currency-usd", "joined_events": return_joined_events, "available_events": return_available_events} active_event = False for event in joined_events: @@ -676,10 +769,34 @@ def get_saving_session_data(self): if start <= self.now_utc and end > self.now_utc: active_event = True break - self.base.dashboard_item(entity_name + "_saving_session", "on" if active_event else "off", attributes=saving_attributes, app="octopus") + self.base.dashboard_item(self.get_entity_name("binary_sensor", "saving_session"), "on" if active_event else "off", attributes=saving_attributes, app="octopus") + + # Create joiner dropdown for available events + possible_codes = [] + for event in available_events: + code = event.get("code", None) + if code: + possible_codes.append(code) + self.base.dashboard_item(self.get_entity_name("select", "saving_session_join"), "", attributes={"options": possible_codes, "friendly_name": "Join Octopus Saving Session Event", "icon": "mdi:currency-usd"}, app="octopus") return return_available_events, return_joined_events + def automatic_config(self, tariffs): + """ + Automatic configuration of entities + """ + self.log("Octopus API: Automatic configuration of entities") + self.base.args["octopus_saving_session"] = self.get_entity_name("binary_sensor", "saving_session") + self.base.args["octopus_saving_session_join"] = self.get_entity_name("select", "saving_session_join") + for tariff in tariffs: + self.base.args["metric_octopus_{}_rates".format(tariff)] = self.get_entity_name("sensor", tariff + "_rates") + self.base.args["metric_octopus_{}_standing".format(tariff)] = self.get_entity_name("sensor", tariff + "_standing") + device = self.get_intelligent_device() + if device: + self.base.args["octopus_intelligent_slot"] = self.get_entity_name("binary_sensor", "intelligent_dispatch") + self.base.args["octopus_ready_time"] = self.get_entity_name("select", "intelligent_target_time") + self.base.args["octopus_charge_limit"] = self.get_entity_name("number", "intelligent_target_soc") + async def async_get_saving_sessions(self, account_id): """ Get the saving sessions @@ -834,28 +951,58 @@ async def fetch_tariffs(self, tariffs): tariffs[tariff]["data"] = await self.fetch_url_cached(f"https://api.octopus.energy/v1/products/{product_code}/{tariff_type}-tariffs/{tariff_code}/standard-unit-rates/") tariffs[tariff]["standing"] = await self.fetch_url_cached(f"https://api.octopus.energy/v1/products/{product_code}/{tariff_type}-tariffs/{tariff_code}/standing-charges/") - entity_id = "sensor.predbat_octopus_" + self.account_id.replace("-", "_") + "_" + tariff - entity_id = entity_id.lower() + rates = self.get_octopus_rates_direct(tariff) + standing = self.get_octopus_rates_direct(tariff, standingCharge=True) - rates = self.base.get_octopus_direct(tariff) - standing = self.base.get_octopus_direct(tariff, standingCharge=True) - - rates_stamp = {} + rates_stamp = [] for minute in range(0, 60 * 24 * 2, 30): time_now = self.midnight_utc + timedelta(minutes=minute) rate_value = rates.get(minute, None) if rate_value is not None: - rates_stamp[time_now.strftime(TIME_FORMAT_OCTOPUS)] = rate_value + start_time = time_now.strftime(TIME_FORMAT) + end_time = (time_now + timedelta(minutes=30)).strftime(TIME_FORMAT) + rates_stamp.append({"start": start_time, "end": end_time, "value_inc_vat": rate_value}) rate_now = rates.get(self.now_utc.minute + self.now_utc.hour * 60, None) standing_now = standing.get(self.now_utc.minute + self.now_utc.hour * 60, None) self.base.dashboard_item( - entity_id, + self.get_entity_name("sensor", tariff + "_rates"), rate_now, - attributes={"friendly_name": "Octopus Tariff " + tariff, "icon": "mdi:currency-gbp", "standing_charge": standing_now, "rates": self.base.filtered_times(rates_stamp), "product_code": product_code, "tariff_code": tariff_code}, + attributes={"friendly_name": "Octopus Tariff Rates " + tariff, "icon": "mdi:currency-gbp", "standing_charge": standing_now, "rates": rates_stamp, "product_code": product_code, "tariff_code": tariff_code}, + app="octopus", + ) + self.base.dashboard_item( + self.get_entity_name("sensor", tariff + "_standing"), + standing_now, + attributes={"friendly_name": "Octopus Tariff Standing Charge " + tariff, "icon": "mdi:currency-gbp", "product_code": product_code, "tariff_code": tariff_code}, app="octopus", ) + def get_octopus_rates_direct(self, tariff_type, standingCharge=False): + """ + Get the direct import rates from Octopus + """ + tariff = self.get_tariff(tariff_type) + if tariff and ("data" in tariff): + if standingCharge: + tariff_data = tariff["standing"] + else: + tariff_data = tariff["data"] + + # For Octopus rate data valid to of None means forever + if tariff_data: + for rate in tariff_data: + valid_to = rate.get("valid_to", None) + if valid_to is None: + rate["valid_to"] = (self.midnight_utc + timedelta(days=7)).strftime(TIME_FORMAT_OCTOPUS) + + pdata = self.base.minute_data(tariff_data, 3, self.midnight_utc, "value_inc_vat", "valid_from", backwards=False, to_key="valid_to") + return pdata + else: + # No tariff + self.log("OctopusDirect: tariff {} not available, using zero".format(tariff_type)) + return {n: 0 for n in range(0, 60 * 24)} + async def async_read_response(self, response, url, ignore_errors=False): """Reads the response, logging any json errors""" @@ -1077,8 +1224,6 @@ async def async_intelligent_update_sensor(self, account_id): planned = intelligent_device.get("planned_dispatches", []) completed = intelligent_device.get("completed_dispatches", []) - entity_name = "binary_sensor.predbat_octopus_" + account_id.replace("-", "_") - entity_name = entity_name.lower() active_event = False for dispatch in planned: start = dispatch.get("start", None) @@ -1089,7 +1234,23 @@ async def async_intelligent_update_sensor(self, account_id): if start <= self.now_utc and end > self.now_utc: active_event = True dispatch_attributes = {"friendly_name": "Octopus Intelligent Dispatches", "icon": "mdi:flash", **intelligent_device} - self.base.dashboard_item(entity_name + "_intelligent_dispatch", "on" if active_event else "off", attributes=dispatch_attributes, app="octopus") + self.base.dashboard_item(self.get_entity_name("binary_sensor", "intelligent_dispatch"), "on" if active_event else "off", attributes=dispatch_attributes, app="octopus") + + weekday_target_time = intelligent_device.get("weekday_target_time", None) + weekday_target_soc = intelligent_device.get("weekday_target_soc", None) + weekend_target_time = intelligent_device.get("weekend_target_time", None) + weekend_target_soc = intelligent_device.get("weekend_target_soc", None) + # Check if we are on a weekend? + if self.now_utc.weekday() >= 5: + target_time = weekend_target_time + target_soc = weekend_target_soc + else: + target_time = weekday_target_time + target_soc = weekday_target_soc + if target_time: + target_time = target_time[:5] # Only HH:MM + self.base.dashboard_item(self.get_entity_name("select", "intelligent_target_time"), target_time, attributes={"friendly_name": "Octopus Intelligent Target Time", "icon": "mdi:clock-outline", "options": OPTIONS_TIME}, app="octopus") + self.base.dashboard_item(self.get_entity_name("number", "intelligent_target_soc"), target_soc, attributes={"friendly_name": "Octopus Intelligent Target SOC", "icon": "mdi:battery-percent", "min": 0, "max": 100}, app="octopus") async def async_get_account(self, account_id): """ @@ -1448,9 +1609,8 @@ def download_octopus_rates(self, url): return pdata # Retry up to 3 minutes - octopus_api_direct = self.components.get_component("octopus") for retry in range(3): - pdata = self.download_octopus_rates_func(url, api=octopus_api_direct) + pdata = self.download_octopus_rates_func(url) if pdata: break @@ -1470,45 +1630,7 @@ def download_octopus_rates(self, url): self.octopus_url_cache[url]["data"] = pdata return pdata - def get_standing_charge_direct(self): - """ - Get standing charge - """ - pdata = self.get_octopus_direct("import", standingCharge=True) - charge_now = pdata.get(self.now_utc.minute + self.now_utc.hour * 60, 0) - return charge_now - - def get_octopus_direct(self, tariff_type, standingCharge=False): - """ - Get the direct import rates from Octopus - """ - octopus_api_direct = self.components.get_component("octopus") - if octopus_api_direct: - tariff = octopus_api_direct.get_tariff(tariff_type) - if tariff and ("data" in tariff): - if standingCharge: - tariff_data = tariff["standing"] - else: - tariff_data = tariff["data"] - - # For Octopus rate data valid to of None means forever - if tariff_data: - for rate in tariff_data: - valid_to = rate.get("valid_to", None) - if valid_to is None: - rate["valid_to"] = (self.midnight_utc + timedelta(days=7)).strftime(TIME_FORMAT_OCTOPUS) - - pdata = self.minute_data(tariff_data, self.forecast_days + 1, self.midnight_utc, "value_inc_vat", "valid_from", backwards=False, to_key="valid_to") - return pdata - else: - # No tariff - self.log("OctopusDirect: tariff {} not available, using zero".format(tariff_type)) - return {n: 0 for n in range(0, 60 * 24)} - - self.log("Warn: OctopusDirect: direct not available (get_octopus_direct tariff {})".format(tariff_type)) - return {} - - def download_octopus_rates_func(self, url, api=None): + def download_octopus_rates_func(self, url): """ Download octopus rates directly from a URL """ @@ -1520,25 +1642,17 @@ def download_octopus_rates_func(self, url, api=None): if self.debug_enable: self.log("Download {}".format(url)) try: - if api: - api.requests_total += 1 r = requests.get(url) except requests.exceptions.ConnectionError: - if api: - api.failures_total += 1 self.log("Warn: Unable to download Octopus data from URL {} (ConnectionError)".format(url)) self.record_status("Warn: Unable to download Octopus data from cloud", debug=url, had_errors=True) return {} if r.status_code not in [200, 201]: - if api: - api.failures_total += 1 self.log("Warn: Error downloading Octopus data from URL {}, code {}".format(url, r.status_code)) self.record_status("Warn: Error downloading Octopus data from cloud", debug=url, had_errors=True) return {} try: data = r.json() - if api: - api.last_success_timestamp = datetime.now(timezone.utc) except requests.exceptions.JSONDecodeError: self.failures_total += 1 self.log("Warn: Error downloading Octopus data from URL {} (JSONDecodeError)".format(url)) @@ -1547,15 +1661,13 @@ def download_octopus_rates_func(self, url, api=None): if "results" in data: mdata += data["results"] else: - if api: - api.failures_total += 1 self.log("Warn: Error downloading Octopus data from URL {} (No Results)".format(url)) self.record_status("Warn: Error downloading Octopus data from cloud", debug=url, had_errors=True) return {} url = data.get("next", None) pages += 1 - pdata = self.minute_data(mdata, self.forecast_days + 1, self.midnight_utc, "value_inc_vat", "valid_from", backwards=False, to_key="valid_to") + pdata = self.minute_data(mdata, 3, self.midnight_utc, "value_inc_vat", "valid_from", backwards=False, to_key="valid_to") return pdata def add_now_to_octopus_slot(self, octopus_slots, now_utc): @@ -1948,8 +2060,7 @@ def fetch_octopus_sessions(self): # Octopus saving session octopus_saving_slots = [] - octopus_api_direct = self.components.get_component("octopus") - if octopus_api_direct or ("octopus_saving_session" in self.args): + if "octopus_saving_session" in self.args: saving_rate = 200 # Default rate if not reported octopoints_per_penny = self.get_arg("octopus_saving_session_octopoints_per_penny", 8) # Default 8 octopoints per found @@ -1957,34 +2068,37 @@ def fetch_octopus_sessions(self): available_events = [] state = False - if octopus_api_direct: - available_events, joined_events = octopus_api_direct.get_saving_session_data() - else: - entity_id = self.get_arg("octopus_saving_session", indirect=False) - if entity_id: - state = self.get_arg("octopus_saving_session", False) + entity_id = self.get_arg("octopus_saving_session", indirect=False) + if entity_id: + state = self.get_arg("octopus_saving_session", False) + joined_events = self.get_state_wrapper(entity_id=entity_id, attribute="joined_events") + if not joined_events: + entity_id = entity_id.replace("binary_sensor.", "event.").replace("_sessions", "_session_events") joined_events = self.get_state_wrapper(entity_id=entity_id, attribute="joined_events") - if not joined_events: - entity_id = entity_id.replace("binary_sensor.", "event.").replace("_sessions", "_session_events") - joined_events = self.get_state_wrapper(entity_id=entity_id, attribute="joined_events") - available_events = self.get_state_wrapper(entity_id=entity_id, attribute="available_events") + available_events = self.get_state_wrapper(entity_id=entity_id, attribute="available_events") if available_events: - for event in available_events: - code = event.get("code", None) # decode the available events structure for code, start/end time & rate - start = event.get("start", None) - end = event.get("end", None) - start_time = str2time(start) # reformat the saving session start & end time for improved readability - end_time = str2time(end) - saving_rate = event.get("octopoints_per_kwh", saving_rate * octopoints_per_penny) / octopoints_per_penny # Octopoints per pence - if code: # Join the new Octopus saving event and send an alert - self.log("Joining Octopus saving event code {} {}-{} at rate {} p/kWh".format(code, start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate)) - if octopus_api_direct: - octopus_api_direct.join_saving_session_event(code) - else: - self.call_service_wrapper("octopus_energy/join_octoplus_saving_session_event", event_code=code, entity_id=entity_id) - self.call_notify("Predbat: Joined Octopus saving event {}-{}, {} p/kWh".format(start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate)) + # Only try to join every 2 hours to avoid spamming if it fails + if not self.octopus_last_joined_try or (self.now_utc - self.octopus_last_joined_try).total_seconds() > 2 * 60 * 60: + for event in available_events: + code = event.get("code", None) # decode the available events structure for code, start/end time & rate + start = event.get("start", None) + end = event.get("end", None) + start_time = str2time(start) # reformat the saving session start & end time for improved readability + end_time = str2time(end) + saving_rate = event.get("octopoints_per_kwh", saving_rate * octopoints_per_penny) / octopoints_per_penny # Octopoints per pence + if code: # Join the new Octopus saving event and send an alert + self.log("Joining Octopus saving event code {} {}-{} at rate {} p/kWh".format(code, start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate)) + entity_id_join = self.get_arg("octopus_saving_session_join", indirect=False) + if entity_id_join: + # Join via selector + self.call_service_wrapper("select/select_option", entity_id=entity_id_join, option=code) + else: + # Join via octopus event (Bottle Cap Dave) + self.call_service_wrapper("octopus_energy/join_octoplus_saving_session_event", event_code=code, entity_id=entity_id) + self.call_notify("Predbat: Joined Octopus saving event {}-{}, {} p/kWh".format(start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate)) + self.octopus_last_joined_try = self.now_utc if joined_events: for event in joined_events: diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 3a8689d39..679fbdd71 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -624,6 +624,7 @@ def reset(self): self.set_export_low_power = False self.config_root = "./" self.inverter_can_charge_during_export = True + self.octopus_last_joined_try = None for root in CONFIG_ROOTS: if os.path.exists(root): diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 31ca8aee9..4e1e7fd72 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -3278,7 +3278,9 @@ def run_single_debug(test_name, my_predbat, debug_file, expected_file=None, comp # my_predbat.metric_self_sufficiency = 5 # my_predbat.calculate_second_pass = False - # my_predbat.best_soc_keep = 1 + my_predbat.best_soc_keep = 0 + my_predbat.rate_low_threshold = 0 + my_predbat.rate_high_threshold = 0 # my_predbat.set_charge_freeze = True # my_predbat.set_export_freeze = True # my_predbat.combine_export_slots = False @@ -8693,7 +8695,6 @@ def run_test_octopus_api(my_predbat, octopus_api, octopus_account): failed = False octopus_api = OctopusAPI(octopus_api, octopus_account, my_predbat) - my_predbat.octopus_api_direct = octopus_api my_predbat.create_task(octopus_api.start()) octopus_api.wait_api_started() @@ -9360,7 +9361,7 @@ async def test_download_octopus_url(my_predbat): test_url = "https://api.octopus.energy/v1/products/VAR-22-11-01/electricity-tariffs/E-2R-VAR-22-11-01-A/standard-unit-rates/" # Test the download function - api = OctopusAPI("", "", my_predbat) + api = OctopusAPI("", "", False, my_predbat) api.now_utc = my_predbat.now_utc rates_data = await api.async_download_octopus_url(test_url) diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 654c2e352..435959ab7 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -1706,7 +1706,7 @@ def render_type(self, arg, value, parent_path="", row_counter=None): text += "