├── .gitignore ├── LICENSE.md ├── README.md └── io.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 liamjvs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intelligent Octopus Scheduler 2 | Quick Python script to query using GraphQL your slots for [Intelligent Octopus](https://octopus.energy/intelligent-octopus/). I use this with Home Assistant to run my automations. 3 | 4 | I haven't explained the logic well in the script but I wanted to prevent my automations from triggering on/off through the day. The script checks if there is a slot adjacent to it and if the slot is in the off-peak period. There are most likely some edge usecases where the script won't output the correct times - in my limited testing (and simluating different use cases) it has been working. 5 | 6 | ## io.py 7 | You'll need to enter the two variables before executing the script: 8 | - [Octopus Developer API Key](https://octopus.energy/dashboard/developer/) 9 | - Octopus Account Number (found on your account section) 10 | 11 | ## Home Assistant 12 | Add the code below to your config to call the python script. 13 | 14 | ```yaml 15 | sensor: 16 | - platform: command_line 17 | name: Intelligent Octopus Times 18 | json_attributes: 19 | - nextRunStart 20 | - nextRunEnd 21 | - timesObj 22 | command: "python3 /config/io.py" 23 | scan_interval: 3600 24 | value_template: "{{ value_json.updatedAt }}" 25 | 26 | template: 27 | - binary_sensor: 28 | - name: "Octopus Intelligent Slot" 29 | state: '{{ as_timestamp(states("sensor.intelligent_octopus_start")) <= as_timestamp(now()) <= as_timestamp(states("sensor.intelligent_octopus_end")) }}' 30 | - sensor: 31 | - name: 'Intelligent Octopus Start' 32 | state: '{{ state_attr("sensor.intelligent_octopus_times","nextRunStart") }}' 33 | - name: 'Intelligent Octopus End' 34 | state: '{{ state_attr("sensor.intelligent_octopus_times","nextRunEnd") }}' 35 | ``` 36 | 37 | In your automations, you can use `binary_sensor.octopus_intelligent_slot`. -------------------------------------------------------------------------------- /io.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import requests,json 3 | from datetime import date, datetime,timezone,timedelta 4 | from requests.models import HTTPError 5 | from zoneinfo import ZoneInfo 6 | 7 | url = "https://api.octopus.energy/v1/graphql/" 8 | apikey="" #Y Your Octopus API Key 9 | accountNumber="" # Your Octopus Account Number 10 | 11 | dateTimeToUse = datetime.now().astimezone() 12 | if dateTimeToUse.hour < 17: 13 | dateTimeToUse = dateTimeToUse-timedelta(days=1) 14 | ioStart = dateTimeToUse.astimezone().replace(hour=23, minute=30, second=0, microsecond=0) 15 | ioEnd = dateTimeToUse.astimezone().replace(microsecond=0).replace(hour=5, minute=30, second=0, microsecond=0)+timedelta(days = 1) 16 | 17 | def refreshToken(apiKey,accountNumber): 18 | try: 19 | query = """ 20 | mutation krakenTokenAuthentication($api: String!) { 21 | obtainKrakenToken(input: {APIKey: $api}) { 22 | token 23 | } 24 | } 25 | """ 26 | variables = {'api': apikey} 27 | r = requests.post(url, json={'query': query , 'variables': variables}) 28 | except HTTPError as http_err: 29 | print(f'HTTP Error {http_err}') 30 | except Exception as err: 31 | print(f'Another error occurred: {err}') 32 | 33 | jsonResponse = json.loads(r.text) 34 | return jsonResponse['data']['obtainKrakenToken']['token'] 35 | 36 | def getObject(): 37 | try: 38 | query = """ 39 | query getData($input: String!) { 40 | plannedDispatches(accountNumber: $input) { 41 | startDt 42 | endDt 43 | } 44 | } 45 | """ 46 | variables = {'input': accountNumber} 47 | headers={"Authorization": authToken} 48 | r = requests.post(url, json={'query': query , 'variables': variables, 'operationName': 'getData'},headers=headers) 49 | return json.loads(r.text)['data'] 50 | except HTTPError as http_err: 51 | print(f'HTTP Error {http_err}') 52 | except Exception as err: 53 | print(f'Another error occurred: {err}') 54 | 55 | def getTimes(): 56 | object = getObject() 57 | return object['plannedDispatches'] 58 | 59 | def returnPartnerSlotStart(startTime): 60 | for x in times: 61 | slotStart = datetime.strptime(x['startDt'],'%Y-%m-%d %H:%M:%S%z') 62 | slotEnd = datetime.strptime(x['endDt'],'%Y-%m-%d %H:%M:%S%z') 63 | if(startTime == slotEnd): 64 | return slotEnd 65 | 66 | def returnPartnerSlotEnd(endTime): 67 | for x in times: 68 | slotStart = datetime.strptime(x['startDt'],'%Y-%m-%d %H:%M:%S%z') 69 | slotEnd = datetime.strptime(x['endDt'],'%Y-%m-%d %H:%M:%S%z') 70 | if(endTime == slotStart): 71 | return slotEnd 72 | 73 | #Get Token 74 | authToken = refreshToken(apikey,accountNumber) 75 | times = getTimes() 76 | 77 | #Convert to the current timezone 78 | for i,time in enumerate(times): 79 | slotStart = datetime.strptime(time['startDt'],'%Y-%m-%d %H:%M:%S%z').astimezone(ZoneInfo("Europe/London")) 80 | slotEnd = datetime.strptime(time['endDt'],'%Y-%m-%d %H:%M:%S%z').astimezone(ZoneInfo("Europe/London")) 81 | time['startDt'] = str(slotStart) 82 | time['endDt'] = str(slotEnd) 83 | times[i] = time 84 | 85 | timeNow = datetime.now(timezone.utc).astimezone() 86 | 87 | #Santise Times 88 | #Remove times within 23:30-05:30 slots 89 | newTimes = [] 90 | addExtraSlot = True 91 | for i,time in enumerate(times): 92 | slotStart = datetime.strptime(time['startDt'],'%Y-%m-%d %H:%M:%S%z').astimezone() 93 | slotEnd = datetime.strptime(time['endDt'],'%Y-%m-%d %H:%M:%S%z').astimezone() 94 | if(not((ioStart <= slotStart <= ioEnd) and (ioStart <= slotEnd <= ioEnd))): 95 | if((slotStart <= ioStart) and (ioStart < slotEnd <= ioEnd)): 96 | time['endDt'] = str(ioStart) 97 | times[i] = time 98 | if((ioStart <= slotStart <= ioEnd) and (ioEnd < slotEnd)): 99 | time['startDt'] = str(ioEnd) 100 | newTimes.append(time) 101 | if((slotStart <= ioStart <= slotEnd) and (slotStart <= ioEnd <= slotEnd)): 102 | #This slot overlaps our IO slot - we need not add it manually at the next step 103 | addExtraSlot = False 104 | times = newTimes 105 | 106 | if(addExtraSlot): 107 | #Add our IO period 108 | ioPeriod = json.loads('[{"startDt": "'+str(ioStart)+'","endDt": "'+str(ioEnd)+'"}]') 109 | times.extend(ioPeriod) 110 | times.sort(key=lambda x: x['startDt']) 111 | 112 | newTimes = [] 113 | #Any partner slots a.k.a. slots next to each other 114 | for i,time in enumerate(times): 115 | while True: 116 | slotStart = datetime.strptime(time['startDt'],'%Y-%m-%d %H:%M:%S%z').astimezone() 117 | slotEnd = datetime.strptime(time['endDt'],'%Y-%m-%d %H:%M:%S%z').astimezone() 118 | if((i+1)