Skip to content

Commit

Permalink
Merge pull request #1 from j-scharrenbach/version2-beta
Browse files Browse the repository at this point in the history
Version 2
  • Loading branch information
j-scharrenbach committed Oct 27, 2021
2 parents bd9736c + a9ffe44 commit 2551df4
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 124 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ Temporary Items
.apdisk

__pycache__
.venv
config.json
37 changes: 21 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# tado-individual-away-control

`tado-individual-away-control` allows to control the away state of your tado heating control by individual users.
It uses pings in the local network to get the state of a desired device (e.g. a smartphone).
It uses the tado api to get the home state of a desired device (e.g. a smartphone).

This software is not developed by Tado itself.

Expand Down Expand Up @@ -30,41 +30,46 @@ Some values are already set up, other ones need to be entered manually (username
"username": "<your-username>",
"password": "<your-password>",
"away_temperature": <int, temperature to be set when in away mode>,
"allow_deep_sleep": <true/false, allow/disallow deep sleep>,
"allow_deep_sleep": <true/false, allow/disallow deep sleep, see below>,
"deep_sleep_after_hours": <float, time in hours after which deep sleep gets enabled>,
"deep_sleep_temperature": <int, temperature to be set when in deep sleep mode>,
"interval": <int, interval of ping events in seconds>,
"max_ping_cnt": <int, number of consecutive pings>,
"client_state_history_len": <int, number of ping events to evaluate for state>,
"min_home_success_pings": <int, minimal number of successfull pings to set state to home>,
"interval": <int, interval in seconds, minimum 15 seconds>,
"default_stale_state": <"STUSTAIN", "HOME" or "AWAY", value to consider the device as if it is stale, see below>,
"print_timestamp": <true/false, print the timestamps in terminal (set to false for privacy reasons)>,
"rules": [
{
"zone_id": <int, id of the zone OR list<int>, zone ids OR "default">,
"ips": [
"<list of ips for the desired zone>"
"zone_id": <int, id of the zone OR list<int>, zone ids OR "default", see below>,
"device": [
"<list of names of the devices to listen for this zone(s), see below>"
]
}
]
}
```

Each rule is defined by the zone id and a list of the devices to look for (make sure the devices have a static ip).
Each rule is defined by the zone id and a list of the devices to look for. The script receives the home state of each device in the defined interval and sets the zones accordingly.

The `zone_id` field can be a single zone id, a list of zone ids (e.g. `[1, 2, 3]`) or the string `"default"`, which applies to all zones no rule is defined for.
Keep in mind: There can only be one rule per zone, otherwise the application will terminate.

Multiple rules aswell as multiple devices are possible. Only if all devices are not available, the state of the zone is away.
The `deep sleep mode` is acitvated, when a zone is set to away mode for `deep_sleep_after_hours` number of hours. Then the temperature is set to `deep_sleep_temperature`.

To list all zones run
```pip packets
```
python3 start.py --list-zones
```

The time needed to recognize a device as away is determined by `interval * client_state_history_len`.
The `device` field needs to be a list of the names of the devices which need to be away from home to activate the corresponsing rule. The names must exactly match those from the Tado configuration. Multiple devices need to be separated via comma.

To list all devices run
```
python3 start.py --list-zones
```

Multiple rules aswell as multiple devices are possible. Only if all devices are not available, the state of the zone is away.
The `deep sleep mode` is acitvated, when a zone is set to away mode for `deep_sleep_after_hours` number of hours. Then the temperature is set to `deep_sleep_temperature`.

The `default_stale_state` defines how the system behaves if a device gets stale (is not responding). `"SUSTAIN"` takes the last known state and continues the corresponding behavior. If the device has (or has not) been at home, is is assumed that the state did not change. `"HOME"` always sets the state to `at_home = true` and `"AWAY"` sets the state to `at_home = false`.

To run the application simply start the `start.py` with sudo rights (those are needed for the pings).
To run the application simply start the `start.py`.

### Privacy notes

Expand Down
12 changes: 5 additions & 7 deletions config-sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,16 @@
"password": "<your-password>",
"away_temperature": 16,
"allow_deep_sleep": true,
"deep_sleep_after_hours": 12.0,
"deep_sleep_after_hours": 24.0,
"deep_sleep_temperature": 14,
"interval": 10,
"max_ping_cnt": 2,
"client_state_history_len": 60,
"min_home_success_pings": 2,
"interval": 30,
"default_stale_state": "SUSTAIN",
"print_timestamp": true,
"rules": [
{
"zone_id": 0,
"ips": [
"<your-ips>"
"device": [
"<your-device-name>"
]
}
]
Expand Down
3 changes: 1 addition & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
python-tado
ping3
python-tado
163 changes: 87 additions & 76 deletions src/App.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
File name: App.py
Author: Jannik Scharrenbach
Date created: 10/10/2020
Date last modified: 09/01/2021
Date last modified: 27/10/2021
Python Version: 3.8
"""

Expand All @@ -12,9 +12,6 @@
from src.ClientState import ClientState as cs
from src.ZoneState import ZoneState as zs

import ping3

import sys
import time


Expand All @@ -24,61 +21,56 @@ def __init__(self):
self.__tado = TadoWrapper()
self.__zone_states = None
self.__zone_off_time = dict()
self.__last_states = {}
self.__geofencing_locked = None

ConfigHelper.initialize_zones(self.__tado.get_zones())

self.__client_states = None
self.__client_state_history = list()
def list_devices(self):
# print all devices connected to the home
print("Devices in home:")
for z in self.__tado.get_devices():
print("\"" + z["name"] + "\"", end="")
if not z["geo_tracking"]:
print(" (no geo tracking enabled!)")
else:
print("")

def list_zones(self):
# print all zones of the home
print("Zones in home:")
for z in self.__tado.get_zones():
print(z["id"], ":\t", z["name"])

def __ping(self, ip):
result = False
for i in range(0, ConfigHelper.get_max_ping_cnt()):
try:
result = ping3.ping(ip, timeout=3) is not None
except OSError:
pass
if result:
break
return result

def __get_client_states(self):
# returns the client states (available via ping or not)
# checks if consecutive pings fail and set to away after defined number of pings
new_c_states = {ip: cs.UNKNOWN for ip in ConfigHelper.get_ips()}

for ip in new_c_states.keys():
r = self.__ping(ip)

if r:
new_c_states[ip] = cs.HOME
# returns the client states (at home or not)
device_states = self.__tado.get_device_athome_states()
client_states = {}

# calculate presence for each device
for d in ConfigHelper.get_devices():
if d in device_states:
state = device_states[d]
if state["stale"]:
# set to default_stale_state if device is stale
default_state = ConfigHelper.get_default_stale_state()
if default_state == "SUSTAIN":
if d in self.__last_states:
client_states[d] = self.__last_states[d]
else:
client_states[d] = cs.HOME
elif default_state == "AWAY":
client_states[d] = cs.AWAY
else:
client_states[d] = cs.HOME
else:
client_states[d] = cs.HOME if state["at_home"] else cs.AWAY
else:
new_c_states[ip] = cs.AWAY

if self.__client_states is None:
self.__client_states = new_c_states

# add to history and clean up
self.__client_state_history.append(new_c_states)
if len(self.__client_state_history) > ConfigHelper.get_client_state_history_len():
self.__client_state_history.pop(0)
raise Exception("Unknown device {}".format(d))

# calculate presence for each ip
for ip in new_c_states.keys():
home_cnt = sum([h[ip] == cs.HOME for h in self.__client_state_history])
self.__last_states = client_states

if home_cnt >= ConfigHelper.get_min_home_success_pings():
# home
self.__client_states[ip] = cs.HOME
else:
# away and steady
self.__client_states[ip] = cs.AWAY

return self.__client_states
return client_states

def __get_desired_zone_states(self, client_states):
# returns the desired states of all zones defined in the config.json
Expand All @@ -87,7 +79,7 @@ def __get_desired_zone_states(self, client_states):
clients_home = set(c[0] for c in client_states.items() if c[1] == cs.HOME)

for r in ConfigHelper.get_rules():
if len(set(r["ips"]).intersection(clients_home)) != 0:
if len(set(r["device"]).intersection(clients_home)) != 0:
z_states[r["zone_id"]] = zs.ON
if r["zone_id"] in self.__zone_off_time.keys():
# remove from off time if turned on
Expand All @@ -105,39 +97,58 @@ def __get_desired_zone_states(self, client_states):

def __update_heating(self):
client_states = self.__get_client_states()
geofencing_locked = self.__tado.is_presence_locked()

# parse rules an get desired states
desired_zone_states = self.__get_desired_zone_states(client_states)

if self.__zone_states is None:
# invert for first run to initially turn everything to the desired state
self.__zone_states = {z[0]: zs.invert(z[1]) for z in desired_zone_states.items()}

# get zones to turn on
turn_on = set(z[0] for z in desired_zone_states.items() if z[1] == zs.ON).intersection(
set(z[0] for z in self.__zone_states.items() if z[1] != zs.ON))

# get zones to turn off
turn_off = set(z[0] for z in desired_zone_states.items() if z[1] == zs.OFF).intersection(
set(z[0] for z in self.__zone_states.items() if z[1] != zs.OFF))

# get zones to turn to deep sleep mode
turn_deep_sleep = set(z[0] for z in desired_zone_states.items() if z[1] == zs.DEEP_SLEEP).intersection(
set(z[0] for z in self.__zone_states.items() if z[1] != zs.DEEP_SLEEP))

for zone in turn_on:
LoggingHelper.log("Switching zone {} to state 'on'... ".format(zone))
self.__tado.reset_zone(zone)

for zone in turn_off:
LoggingHelper.log("Switching zone {} to state 'off'... ".format(zone))
self.__tado.set_zone(zone, ConfigHelper.get_away_temperature())

for zone in turn_deep_sleep:
LoggingHelper.log("Switching zone {} to state 'deep sleep'... ".format(zone))
self.__tado.set_zone(zone, ConfigHelper.get_deep_sleep_temperature())

self.__zone_states = desired_zone_states
if not geofencing_locked:
# geofencing not locked, contiue regular operation
if self.__geofencing_locked:
LoggingHelper.log("Geofencing unlocked, continue regular operation...")

if self.__zone_states is None:
# invert for first run to initially turn everything to the desired state
self.__zone_states = {z[0]: zs.invert(z[1]) for z in desired_zone_states.items()}

# get zones to turn on
turn_on = set(z[0] for z in desired_zone_states.items() if z[1] == zs.ON).intersection(
set(z[0] for z in self.__zone_states.items() if z[1] != zs.ON))

# get zones to turn off
turn_off = set(z[0] for z in desired_zone_states.items() if z[1] == zs.OFF).intersection(
set(z[0] for z in self.__zone_states.items() if z[1] != zs.OFF))

# get zones to turn to deep sleep mode
turn_deep_sleep = set(z[0] for z in desired_zone_states.items() if z[1] == zs.DEEP_SLEEP).intersection(
set(z[0] for z in self.__zone_states.items() if z[1] != zs.DEEP_SLEEP))

for zone in turn_on:
LoggingHelper.log("Switching zone {} to state 'on'... ".format(zone))
self.__tado.reset_zone(zone)

for zone in turn_off:
LoggingHelper.log("Switching zone {} to state 'off'... ".format(zone))
self.__tado.set_zone(zone, ConfigHelper.get_away_temperature())

for zone in turn_deep_sleep:
LoggingHelper.log("Switching zone {} to state 'deep sleep'... ".format(zone))
self.__tado.set_zone(zone, ConfigHelper.get_deep_sleep_temperature())

self.__zone_states = desired_zone_states

elif not self.__geofencing_locked and geofencing_locked:
# reset all zones which are set to off
if self.__zone_states:
off_zone_ids = [z[0] for z in self.__zone_states.items() if z[1] == zs.OFF]

LoggingHelper.log("Geofencing locked, resetting all zones which are turned off...")
for zone_id in off_zone_ids:
self.__tado.reset_zone(zone_id)

LoggingHelper.log("Pausing until geofencing is unlocked.")

self.__geofencing_locked = geofencing_locked

def run(self):
while 1:
Expand Down
36 changes: 22 additions & 14 deletions src/ConfigHelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
File name: ConfigHelper.py
Author: Jannik Scharrenbach
Date created: 10/10/2020
Date last modified: 15/04/2021
Date last modified: 06/10/2021
Python Version: 3.8
"""

Expand All @@ -16,16 +16,15 @@ class ConfigHelper:

__RULES = None
__ZONES = None
__DEVICES = None

MIN_INTERVAL = 15

@staticmethod
def initialize():
if ConfigHelper.__CONFIG is None:
try:
with open(os.path.dirname(__file__) + "/" + os.path.pardir + "/config.json") as f:
ConfigHelper.__CONFIG = json.load(f)
except FileNotFoundError:
print("ERROR: config.json not found.\nPlease set up the configuration according to your tado setup. Read README.md for further information.")
sys.exit(1)
with open(os.path.dirname(__file__) + "/" + os.path.pardir + "/config.json") as f:
ConfigHelper.__CONFIG = json.load(f)

@staticmethod
def initialize_zones(zones):
Expand All @@ -37,14 +36,19 @@ def get_credentials():
return ConfigHelper.__CONFIG["username"], ConfigHelper.__CONFIG["password"]

@staticmethod
def get_ips():
ips = set()
def get_default_stale_state():
return ConfigHelper.__CONFIG["default_stale_state"]

for r in ConfigHelper.get_rules():
for ip in r["ips"]:
ips.add(ip)
@staticmethod
def get_devices():
if not ConfigHelper.__DEVICES:
devices = set()
for r in ConfigHelper.get_rules():
for d in r["device"]:
devices.add(d)

return list(ips)
ConfigHelper.__DEVICES = list(devices)
return ConfigHelper.__DEVICES

@staticmethod
def get_rules():
Expand Down Expand Up @@ -88,7 +92,7 @@ def get_rules():

@staticmethod
def get_interval():
return int(ConfigHelper.__CONFIG["interval"])
return max(ConfigHelper.MIN_INTERVAL, int(ConfigHelper.__CONFIG["interval"]))

@staticmethod
def get_zones():
Expand Down Expand Up @@ -118,6 +122,10 @@ def get_print_timestamp():
def get_allow_deep_sleep():
return bool(ConfigHelper.__CONFIG["allow_deep_sleep"])

@staticmethod
def get_allow_deep_sleep():
return bool(ConfigHelper.__CONFIG["allow_deep_sleep"])

@staticmethod
def get_deep_sleep_after_seconds():
return float(ConfigHelper.__CONFIG["deep_sleep_after_hours"]) * 60 * 60
Expand Down
Loading

0 comments on commit 2551df4

Please sign in to comment.