-
-
Save adampetrovic/34eeefd3af3e055fb59b077eacab06a1 to your computer and use it in GitHub Desktop.
from dateutil import parser, tz | |
from datetime import datetime, timezone | |
MAX_TIME_RANGE = 12 * 60 # how many minutes in the future to look at forecasts | |
BATTERY_CAPACITY = 82 # kWh - Tesla Model 3 Performance (2021) | |
CHARGE_RATE = 11 # kWh - Gen 3 Wall Connector (3 Phase 16A) | |
LOCAL_TZ = tz.gettz('Australia/Sydney') | |
def get_price_forecast(): | |
# initialise prices with current price | |
prices = [state.getattr(sensor.amber_general_price)] | |
forecasts = sorted(sensor.amber_general_forecast.forecasts, key=lambda x: parser.parse(x['start_time'])) | |
times_seen = 0 | |
while len(forecasts) and (times_seen < MAX_TIME_RANGE): | |
forecast = forecasts.pop(0) | |
times_seen += forecast['duration'] | |
prices.append(forecast) | |
return prices | |
def format_charge_spots(charge_spots): | |
spots = [] | |
for spot in charge_spots: | |
spots.append({ | |
'start_time': parser.parse(spot['start_time']).astimezone(LOCAL_TZ), | |
'end_time': parser.parse(spot['end_time']).astimezone(LOCAL_TZ), | |
'per_kwh': spot['per_kwh'], | |
}) | |
return spots | |
@state_trigger("sensor.amber_general_forecast", "binary_sensor.tesla_plugged_in == 'on'") # trigger: if our price forecast changes | |
@state_active("input_boolean.tesla_optimal_charging == 'on' and device_tracker.venom == 'home'") # condition: car is at home | |
def cheapest_charging_strategy(**kwargs): | |
charge_time_remaining = 0.0 | |
# is the car drawing power from the charger? | |
if float(sensor.tesla_power) == 0.0: | |
# need to calculate remaining charge time ourselves. | |
current_battery = float(sensor.tesla_battery_level) | |
desired_battery = float(sensor.tesla_charge_limit_soc) | |
charge_time_remaining = (((((desired_battery - current_battery) / 100) * BATTERY_CAPACITY) / CHARGE_RATE) * 60) | |
else: | |
charge_time_remaining = float(sensor.tesla_time_to_full_charge) * 60 | |
# do we actually need to charge the car? | |
if charge_time_remaining <= 0: | |
state.set('sensor.tesla_charging_strategy', value='stopped', new_attributes={ | |
'next_start_time': None, | |
'next_end_time': None, | |
'reason': 'fully charged', | |
}) | |
return | |
# sort prices by lowest $/kwh, prioritising earlier slots over later slots | |
prices = sorted(get_price_forecast(), key=lambda x: (float(x['per_kwh']), parser.parse(x['start_time']))) | |
charge_spots = [] | |
allocated_charge_time = 0.0 | |
for price in prices: | |
allocated_charge_time += price['duration'] | |
charge_spots.append(price) | |
# we have enough slots, stop. | |
if allocated_charge_time >= charge_time_remaining: | |
break | |
# sort charge spots by earliest time | |
charge_spots = sorted(charge_spots, key=lambda x: parser.parse(x['start_time'])) | |
start_time = parser.parse(charge_spots[0]['start_time']) | |
end_time = parser.parse(charge_spots[0]['end_time']) | |
kwh = charge_spots[0]['per_kwh'] | |
# are we currently in a selected slot? | |
if start_time < datetime.now(timezone.utc) <= end_time: | |
switch.venom_charger_switch.turn_on() | |
if sensor.tesla_charging_strategy == 'stopped': | |
notify.phones(title="Tesla Charger", | |
message="Charging Started. Cost {}c/kWh.".format(kwh)) | |
state.set('sensor.tesla_charging_strategy', value='started', new_attributes={ | |
'charge_time_remaining': charge_time_remaining, | |
'next_start_time': start_time.astimezone(LOCAL_TZ), | |
'next_end_time': end_time.astimezone(LOCAL_TZ), | |
'price_kwh': kwh, | |
'reason': 'cheap', | |
'slots': format_charge_spots(charge_spots), | |
}) | |
else: | |
switch.venom_charger_switch.turn_off() | |
if sensor.tesla_charging_strategy == 'started': | |
notify.phones(title="Tesla Charger", | |
message="Charging Stopped. Cost {}c/kWh. Starting again at {}".format(kwh, start_time.astimezone(LOCAL_TZ).strftime("%-I:%M%p").lower())) | |
state.set('sensor.tesla_charging_strategy', value='stopped', new_attributes={ | |
'charge_time_remaining': charge_time_remaining, | |
'next_start_time': start_time.astimezone(LOCAL_TZ), | |
'next_end_time': end_time.astimezone(LOCAL_TZ), | |
'price_kwh': sensor.amber_general_price, | |
'reason': 'too expensive', | |
'slots': format_charge_spots(charge_spots), | |
}) |
Hey Steve. It's using pyscript which is a custom component of the fantastic HomeAssistant project.
Feel free to fork it and have a play. This does what I need it to for the time being, but I was hoping to extend out the configuration parameters to be more user configurable through the HomeAssistant. e.g. the globals at the top of this file could be input_number
s instead, which would allow me to change thresholds, min / max charging times etc.
Hi Adam
Trying to set up something similar for my model 3. Are you controlling the on/off through the charger or the car itself? I can see the venom_charger_switch reference but not sure what this is.
Thanks
Jarrad
Hi @J-Rod-16
This is a python script that uses HomeAssistant’s pyscript integration as an execution environment. It’s not intended to be used as a standalone script.
The charger switch is an entity that gets exposed when you setup the Tesla Custom Integration (https://github.com/alandtse/tesla) that controls the state of charging through Tesla’s vehicle API
Thanks for the quick reply. I hadn’t see that Tesla Custom Integration before. I’ll try it out once I get my Home Assistant set up on a raspberry pi.
Hi Adam,
I'm super keen to see where this project goes. I had/have a similar proof of concept going with node-red, but would prefer to code it in python.
What's the state machine framework that this is built for? Would you like some (amateur) help with this?
Thx