Skip to content

Commit a283c21

Browse files
merllrytilahti
authored andcommitted
Decoding presets in status messages (#33)
* Modified status structure to process presets where available. * Enhanced client to use presets. * Enhanced CLI to display current presets. * Updated readme. * Cleaned up imports.
1 parent 64cb103 commit a283c21

File tree

5 files changed

+139
-21
lines changed

5 files changed

+139
-21
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ with support for more features and better device handling.
1111
* Writing settings: target temperature, auto mode presets, temperature offset
1212
* Setting the active mode: auto, manual, boost, away
1313
* Reading the device serial number and firmware version
14+
* Reading presets and temperature offset in more recent firmware versions.
1415

1516
## Not (yet) supported)
1617

17-
* Reading presets, temperature offset. This may not be possible.
1818
* No easy-to-use interface for setting schedules.
1919

2020
# Installation
@@ -71,8 +71,12 @@ eq3cli
7171
Locked: False
7272
Batter low: False
7373
Window open: False
74+
Window open temp: 12.0
75+
Window open time: 0:15:00
7476
Boost: False
7577
Current target temp: 17.0
78+
Current comfort temp: 20.0
79+
Current eco temp: 17.0
7680
Current mode: auto dst locked
7781
Valve: 0
7882
```

eq3bt/eq3btsmart.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
import logging
1111
import struct
1212
import codecs
13-
from datetime import datetime, timedelta, time
13+
from datetime import datetime, timedelta
14+
from construct import Byte
1415
from enum import IntEnum
1516

1617
from .connection import BTLEConnection
17-
from .structures import *
18+
from .structures import AwayDataAdapter, DeviceId, Schedule, Status
1819

1920
_LOGGER = logging.getLogger(__name__)
2021

@@ -80,6 +81,12 @@ def __init__(self, _mac, connection_cls=BTLEConnection):
8081

8182
self._schedule = {}
8283

84+
self._window_open_temperature = None
85+
self._window_open_time = None
86+
self._comfort_temperature = None
87+
self._eco_temperature = None
88+
self._temperature_offset = None
89+
8390
self._away_temp = EQ3BT_AWAY_TEMP
8491
self._away_duration = timedelta(days=30)
8592
self._away_end = None
@@ -143,10 +150,30 @@ def handle_notification(self, data):
143150
else:
144151
self._mode = Mode.Auto
145152

146-
_LOGGER.debug("Valve state: %s", self._valve_state)
147-
_LOGGER.debug("Mode: %s", self.mode_readable)
148-
_LOGGER.debug("Target temp: %s", self._target_temperature)
149-
_LOGGER.debug("Away end: %s", self._away_end)
153+
presets = status.presets
154+
if presets:
155+
self._window_open_temperature = presets.window_open_temp
156+
self._window_open_time = presets.window_open_time
157+
self._comfort_temperature = presets.comfort_temp
158+
self._eco_temperature = presets.eco_temp
159+
self._temperature_offset = presets.offset
160+
else:
161+
self._window_open_temperature = None
162+
self._window_open_time = None
163+
self._comfort_temperature = None
164+
self._eco_temperature = None
165+
self._temperature_offset = None
166+
167+
_LOGGER.debug("Valve state: %s", self._valve_state)
168+
_LOGGER.debug("Mode: %s", self.mode_readable)
169+
_LOGGER.debug("Target temp: %s", self._target_temperature)
170+
_LOGGER.debug("Away end: %s", self._away_end)
171+
_LOGGER.debug("Window open temp: %s",
172+
self._window_open_temperature)
173+
_LOGGER.debug("Window open time: %s", self._window_open_time)
174+
_LOGGER.debug("Comfort temp: %s", self._comfort_temperature)
175+
_LOGGER.debug("Eco temp: %s", self._eco_temperature)
176+
_LOGGER.debug("Temp offset: %s", self._temperature_offset)
150177

151178
elif data[0] == PROP_SCHEDULE_RETURN:
152179
parsed = self.parse_schedule(data)
@@ -338,6 +365,16 @@ def window_open_config(self, temperature, duration):
338365
int(temperature * 2), int(duration.seconds / 300))
339366
self._conn.make_request(PROP_WRITE_HANDLE, value)
340367

368+
@property
369+
def window_open_temperature(self):
370+
"""The temperature to set when an open window is detected."""
371+
return self._window_open_temperature
372+
373+
@property
374+
def window_open_time(self):
375+
"""Timeout to reset the thermostat after an open window is detected."""
376+
return self._window_open_time
377+
341378
@property
342379
def locked(self):
343380
"""Returns True if the thermostat is locked."""
@@ -365,6 +402,22 @@ def temperature_presets(self, comfort, eco):
365402
int(eco * 2))
366403
self._conn.make_request(PROP_WRITE_HANDLE, value)
367404

405+
@property
406+
def comfort_temperature(self):
407+
"""Returns the comfort temperature preset of the thermostat."""
408+
return self._comfort_temperature
409+
410+
@property
411+
def eco_temperature(self):
412+
"""Returns the eco temperature preset of the thermostat."""
413+
return self._eco_temperature
414+
415+
@property
416+
def temperature_offset(self):
417+
"""Returns the thermostat's temperature offset."""
418+
return self._temperature_offset
419+
420+
@temperature_offset.setter
368421
def temperature_offset(self, offset):
369422
"""Sets the thermostat's temperature offset."""
370423
_LOGGER.debug("Setting offset: %s", offset)

eq3bt/eq3cli.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,19 +103,29 @@ def low_battery(dev):
103103
def window_open(dev, temp, duration):
104104
""" Gets and sets the window open settings. """
105105
click.echo("Window open: %s" % dev.window_open)
106+
if dev.window_open_temperature is not None:
107+
click.echo("Window open temp: %s" % dev.window_open_temperature)
108+
if dev.window_open_time is not None:
109+
click.echo("Window open time: %s" % dev.window_open_time)
106110
if temp and duration:
107111
click.echo("Setting window open conf, temp: %s duration: %s" % (temp, duration))
108112
dev.window_open_config(temp, duration)
109113

110114

111115
@cli.command()
112-
@click.option('--comfort', type=float)
113-
@click.option('--eco', type=float)
116+
@click.option('--comfort', type=float, required=False)
117+
@click.option('--eco', type=float, required=False)
114118
@pass_dev
115119
def presets(dev, comfort, eco):
116120
""" Sets the preset temperatures for auto mode. """
117-
click.echo("Setting presets: comfort %s, eco %s" % (comfort, eco))
118-
dev.temperature_presets(comfort, eco)
121+
if dev.comfort_temperature is not None:
122+
click.echo("Current comfort temp: %s" % dev.comfort_temperature)
123+
if dev.eco_temperature is not None:
124+
click.echo("Current eco temp: %s" % dev.eco_temperature)
125+
if comfort and eco:
126+
click.echo("Setting presets: comfort %s, eco %s" % (comfort, eco))
127+
dev.temperature_presets(comfort, eco)
128+
119129

120130
@cli.command()
121131
@pass_dev
@@ -132,13 +142,18 @@ def schedule(dev):
132142
click.echo("\t[%s-%s] %s" % (current_hour, hour.next_change_at, hour.target_temp))
133143
current_hour = hour.next_change_at
134144

145+
135146
@cli.command()
136-
@click.argument('offset', type=float)
147+
@click.argument('offset', type=float, required=False)
137148
@pass_dev
138149
def offset(dev, offset):
139150
""" Sets the temperature offset [-3,5 3,5] """
140-
click.echo("Setting the offset to %s" % offset)
141-
dev.temperature_offset(offset)
151+
if dev.temperature_offset is not None:
152+
click.echo("Current temp offset: %s" % dev.temperature_offset)
153+
if offset is not None:
154+
click.echo("Setting the offset to %s" % offset)
155+
dev.temperature_offset = offset
156+
142157

143158
@cli.command()
144159
@click.argument('away_end', type=Datetime(format='%Y-%m-%d %H:%M'), default=None, required=False)
@@ -173,7 +188,8 @@ def state(ctx):
173188
ctx.forward(window_open)
174189
ctx.forward(boost)
175190
ctx.forward(temp)
176-
# ctx.forward(presets)
191+
ctx.forward(presets)
192+
ctx.forward(offset)
177193
ctx.forward(mode)
178194
ctx.forward(valve_state)
179195

eq3bt/structures.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
""" Contains construct adapters and structures. """
22
from construct import (Struct, Adapter, Int8ub, Enum, FlagsEnum, Const,
3-
GreedyRange, GreedyBytes, IfThenElse, Bytes, Byte)
4-
from datetime import datetime, time
3+
GreedyRange, IfThenElse, Bytes, Optional)
4+
from datetime import datetime, time, timedelta
55

66

77
PROP_ID_RETURN = 1
@@ -37,6 +37,33 @@ def _decode(self, obj, ctx, path):
3737
def _encode(self, obj, ctx, path):
3838
return int(obj * 2.0)
3939

40+
41+
class WindowOpenTimeAdapter(Adapter):
42+
""" Adapter to encode and decode window open times (5 min increments). """
43+
def _decode(self, obj, context, path):
44+
return timedelta(minutes=float(obj * 5.0))
45+
46+
def _encode(self, obj, context, path):
47+
if isinstance(obj, timedelta):
48+
obj = obj.seconds
49+
if 0 <= obj <= 3600.0:
50+
return int(obj / 300.0)
51+
raise ValueError("Window open time must be between 0 and 60 minutes "
52+
"in intervals of 5 minutes.")
53+
54+
55+
class TempOffsetAdapter(Adapter):
56+
""" Adapter to encode and decode the temperature offset. """
57+
def _decode(self, obj, context, path):
58+
return float((obj - 7) / 2.0)
59+
60+
def _encode(self, obj, context, path):
61+
if -3.5 <= obj <= 3.5:
62+
return int(obj * 2.0) + 7
63+
raise ValueError("Temperature offset must be between -3.5 and 3.5 (in "
64+
"intervals of 0.5).")
65+
66+
4067
ModeFlags = "ModeFlags" / FlagsEnum(Int8ub,
4168
AUTO=0x00, # always True, doesnt affect building
4269
MANUAL=0x01,
@@ -86,9 +113,16 @@ def _decode(self, obj, context, path):
86113
"valve" / Int8ub,
87114
Const(0x04, Int8ub),
88115
"target_temp" / TempAdapter(Int8ub),
89-
"away" / IfThenElse(lambda ctx: ctx.mode.AWAY,
90-
AwayDataAdapter(Byte[4]),
91-
GreedyBytes),
116+
"away" / IfThenElse(lambda ctx: ctx.mode.AWAY,
117+
AwayDataAdapter(Bytes(4)),
118+
Optional(Bytes(4))),
119+
"presets" / Optional(Struct(
120+
"window_open_temp" / TempAdapter(Int8ub),
121+
"window_open_time" / WindowOpenTimeAdapter(Int8ub),
122+
"comfort_temp" / TempAdapter(Int8ub),
123+
"eco_temp" / TempAdapter(Int8ub),
124+
"offset" / TempOffsetAdapter(Int8ub),
125+
))
92126
)
93127

94128
Schedule = "Schedule" / Struct(

eq3bt/tests/test_thermostat.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from unittest import TestCase
22

33
import codecs
4-
from datetime import datetime
4+
from datetime import datetime, timedelta
55

66
from eq3bt import Thermostat, TemperatureException
77
from eq3bt.eq3btsmart import (PROP_NTFY_HANDLE, PROP_ID_QUERY,
@@ -17,6 +17,7 @@
1717
'boost': b'020104000428',
1818
'low_batt': b'020180000428',
1919
'valve_at_22': b'020100160428',
20+
'presets': b'020100000422000000001803282207',
2021
}
2122

2223

@@ -100,6 +101,16 @@ def test_update(self):
100101
self.assertTrue(th.boost)
101102
self.assertEqual(th.mode, Mode.Boost)
102103

104+
def test_presets(self):
105+
th = self.thermostat
106+
self.thermostat._conn.set_status('presets')
107+
self.thermostat.update()
108+
self.assertEqual(th.window_open_temperature, 12.0)
109+
self.assertEqual(th.window_open_time, timedelta(minutes=15.0))
110+
self.assertEqual(th.comfort_temperature, 20.0)
111+
self.assertEqual(th.eco_temperature, 17.0)
112+
self.assertEqual(th.temperature_offset, 0)
113+
103114
def test_query_schedule(self):
104115
self.fail()
105116

0 commit comments

Comments
 (0)