Skip to content

Commit 64cb103

Browse files
merllrytilahti
authored andcommitted
Adding device serial number and firmware (#31)
* Implemented some essential tests. * Added device serial and firware version. * Removed duplicate property. * Changed CLI command name; only fetch on request. * Removed assumptions on unknown bytes; added some essential tests. * Updated readme. * Hound fixes.
1 parent 595459d commit 64cb103

5 files changed

Lines changed: 138 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ with support for more features and better device handling.
1010
* Reading device status: locked, low battery, valve state, window open, target temperature, active mode
1111
* Writing settings: target temperature, auto mode presets, temperature offset
1212
* Setting the active mode: auto, manual, boost, away
13+
* Reading the device serial number and firmware version
1314

1415
## Not (yet) supported)
1516

eq3bt/eq3btsmart.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ def __init__(self, _mac, connection_cls=BTLEConnection):
8484
self._away_duration = timedelta(days=30)
8585
self._away_end = None
8686

87+
self._firmware_version = None
88+
self._device_serial = None
89+
8790
self._conn = connection_cls(_mac)
8891
self._conn.set_callback(PROP_NTFY_HANDLE, self.handle_notification)
8992

@@ -149,9 +152,21 @@ def handle_notification(self, data):
149152
parsed = self.parse_schedule(data)
150153
self._schedule[parsed.day] = parsed
151154

155+
elif data[0] == PROP_ID_RETURN:
156+
parsed = DeviceId.parse(data)
157+
_LOGGER.debug("Parsed device data: %s", parsed)
158+
self._firmware_version = parsed.version
159+
self._device_serial = parsed.serial
160+
152161
else:
153162
_LOGGER.debug("Unknown notification %s (%s)", data[0], codecs.encode(data, 'hex'))
154163

164+
def query_id(self):
165+
"""Query device identification information, e.g. the serial number."""
166+
_LOGGER.debug("Querying id..")
167+
value = struct.pack('B', PROP_ID_QUERY)
168+
self._conn.make_request(PROP_WRITE_HANDLE, value)
169+
155170
def update(self):
156171
"""Update the data from the thermostat. Always sets the current time."""
157172
_LOGGER.debug("Querying the device..")
@@ -388,5 +403,11 @@ def max_temp(self):
388403
return EQ3BT_MAX_TEMP
389404

390405
@property
391-
def away_end(self):
392-
return self._away_end
406+
def firmware_version(self):
407+
"""Return the firmware version."""
408+
return self._firmware_version
409+
410+
@property
411+
def device_serial(self):
412+
"""Return the device serial number."""
413+
return self._device_serial

eq3bt/eq3cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ def away(dev, away_end, temperature):
152152
click.echo("Disabling away mode")
153153
dev.set_away(away_end, temperature)
154154

155+
156+
@cli.command()
157+
@pass_dev
158+
def device(dev):
159+
""" Displays basic device information. """
160+
dev.query_id()
161+
click.echo("Firmware version: %s" % dev.firmware_version)
162+
click.echo("Device serial: %s" % dev.device_serial)
163+
164+
155165
@cli.command()
156166
@click.pass_context
157167
def state(ctx):

eq3bt/structures.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
""" Contains construct adapters and structures. """
2-
from construct import Struct, Adapter, ExprAdapter, Int8ub, Enum, FlagsEnum, Const, Pass, GreedyRange, GreedyBytes, IfThenElse, If, Bytes, Byte
3-
import struct
2+
from construct import (Struct, Adapter, Int8ub, Enum, FlagsEnum, Const,
3+
GreedyRange, GreedyBytes, IfThenElse, Bytes, Byte)
44
from datetime import datetime, time
5-
from math import floor
65

6+
7+
PROP_ID_RETURN = 1
78
PROP_INFO_RETURN = 2
89
PROP_SCHEDULE_SET = 0x10
910
PROP_SCHEDULE_RETURN = 0x21
@@ -71,6 +72,13 @@ def _encode(self, obj, ctx, path):
7172
return (obj.day, year, hour, obj.month)
7273

7374

75+
class DeviceSerialAdapter(Adapter):
76+
""" Adapter to decode the device serial number. """
77+
def _decode(self, obj, context, path):
78+
return bytearray(n - 0x30
79+
for n in obj).decode()
80+
81+
7482
Status = "Status" / Struct(
7583
"cmd" / Const(PROP_INFO_RETURN, Int8ub),
7684
Const(0x01, Int8ub),
@@ -93,3 +101,12 @@ def _encode(self, obj, ctx, path):
93101
"next_change_at" / TimeAdapter(Int8ub),
94102
)),
95103
)
104+
105+
DeviceId = "DeviceId" / Struct(
106+
"cmd" / Const(PROP_ID_RETURN, Int8ub),
107+
"version" / Int8ub,
108+
Int8ub,
109+
Int8ub,
110+
"serial" / DeviceSerialAdapter(Bytes(10)),
111+
Int8ub,
112+
)

eq3bt/tests/test_thermostat.py

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,51 @@
11
from unittest import TestCase
2+
3+
import codecs
4+
from datetime import datetime
5+
26
from eq3bt import Thermostat, TemperatureException
7+
from eq3bt.eq3btsmart import (PROP_NTFY_HANDLE, PROP_ID_QUERY,
8+
PROP_INFO_QUERY, Mode)
9+
10+
11+
ID_RESPONSE = b'01780000807581626163606067659e'
12+
STATUS_RESPONSES = {
13+
'auto': b'020100000428',
14+
'manual': b'020101000428',
15+
'window': b'020110000428',
16+
'away': b'0201020004231d132e03',
17+
'boost': b'020104000428',
18+
'low_batt': b'020180000428',
19+
'valve_at_22': b'020100160428',
20+
}
21+
322

423
class FakeConnection:
524
def __init__(self, mac):
6-
pass
25+
self._callbacks = {}
26+
self._res = 'auto'
727

828
def set_callback(self, handle, cb):
9-
pass
29+
self._callbacks[handle] = cb
30+
31+
def set_status(self, key):
32+
if key in STATUS_RESPONSES:
33+
self._res = key
34+
else:
35+
raise ValueError("Invalid key for status test response.")
36+
37+
def make_request(self, handle, value, timeout=1, with_response=True):
38+
"""Write a GATT Command without callback - not utf-8."""
39+
if with_response:
40+
cb = self._callbacks.get(PROP_NTFY_HANDLE)
41+
42+
if value[0] == PROP_ID_QUERY:
43+
data = ID_RESPONSE
44+
elif value[0] == PROP_INFO_QUERY:
45+
data = STATUS_RESPONSES[self._res]
46+
else:
47+
return
48+
cb(codecs.decode(data, 'hex'))
1049

1150

1251
class TestThermostat(TestCase):
@@ -28,8 +67,38 @@ def test_parse_schedule(self):
2867
def test_handle_notification(self):
2968
self.fail()
3069

70+
def test_query_id(self):
71+
self.thermostat.query_id()
72+
self.assertEqual(self.thermostat.firmware_version, 120)
73+
self.assertEqual(self.thermostat.device_serial, "PEQ2130075")
74+
3175
def test_update(self):
32-
self.fail()
76+
th = self.thermostat
77+
78+
th._conn.set_status('auto')
79+
th.update()
80+
self.assertEqual(th.valve_state, 0)
81+
self.assertEqual(th.mode, Mode.Auto)
82+
self.assertEqual(th.target_temperature, 20.0)
83+
self.assertFalse(th.locked)
84+
self.assertFalse(th.low_battery)
85+
self.assertFalse(th.boost)
86+
self.assertFalse(th.window_open)
87+
88+
th._conn.set_status('manual')
89+
th.update()
90+
self.assertTrue(th.mode, Mode.Manual)
91+
92+
th._conn.set_status('away')
93+
th.update()
94+
self.assertEqual(th.mode, Mode.Away)
95+
self.assertEqual(th.target_temperature, 17.5)
96+
self.assertEqual(th.away_end, datetime(2019, 3, 29, 23, 00))
97+
98+
th._conn.set_status('boost')
99+
th.update()
100+
self.assertTrue(th.boost)
101+
self.assertEqual(th.mode, Mode.Boost)
33102

34103
def test_query_schedule(self):
35104
self.fail()
@@ -53,10 +122,16 @@ def test_boost(self):
53122
self.fail()
54123

55124
def test_valve_state(self):
56-
self.fail()
125+
th = self.thermostat
126+
th._conn.set_status('valve_at_22')
127+
th.update()
128+
self.assertEqual(th.valve_state, 22)
57129

58130
def test_window_open(self):
59-
self.fail()
131+
th = self.thermostat
132+
th._conn.set_status('window')
133+
th.update()
134+
self.assertTrue(th.window_open)
60135

61136
def test_window_open_config(self):
62137
self.fail()
@@ -65,7 +140,10 @@ def test_locked(self):
65140
self.fail()
66141

67142
def test_low_battery(self):
68-
self.fail()
143+
th = self.thermostat
144+
th._conn.set_status('low_batt')
145+
th.update()
146+
self.assertTrue(th.low_battery)
69147

70148
def test_temperature_offset(self):
71149
self.fail()

0 commit comments

Comments
 (0)