Skip to content

Commit c8ffd07

Browse files
authored
Merge pull request #224 from ikalchev/v2.7.0
V2.7.0
2 parents a7424c1 + 856c0fe commit c8ffd07

12 files changed

Lines changed: 238 additions & 20 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ Sections
1616
### Developers
1717
-->
1818

19+
## [2.7.0] - 2020-01-26
20+
21+
### Added
22+
- Example Accessory that exposes the raspberry pi GPIO pins as a relay. [#220](https://github.com/ikalchev/HAP-python/pull/220)
23+
24+
### Fixed
25+
- The HAP server is now HTTP version 1.1. [#216](https://github.com/ikalchev/HAP-python/pull/216)
26+
- Fixed an issue where accessories on the server can appear non-responsive. [#216](https://github.com/ikalchev/HAP-python/pull/216)
27+
- Correctly end HAP responses in some error cases. [#217](https://github.com/ikalchev/HAP-python/pull/217)
28+
- Fixed an issue where an accessory can appear as non-responsive after an event. Events for value updates will not be sent to the client that initiated them. [#215](https://github.com/ikalchev/HAP-python/pull/215)
29+
1930
## [2.6.0] - 2019-09-21
2031

2132
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Main features:
1111
* Camera - HAP-python supports the camera accessory from version 2.3.0!
1212
* asyncio support - You can run various tasks or accessories in the event loop.
1313
* Out of the box support for Apple-defined services - see them in [the resources folder](pyhap/resources).
14-
* Secure pairing by just scannig the QR code.
14+
* Secure pairing by just scanning the QR code.
1515
* Integrated with the home automation framework [Home Assistant](https://github.com/home-assistant/home-assistant).
1616

1717
The project was developed for a Raspberry Pi, but it should work on other platforms. To kick-start things,

accessories/RPI_Relay.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# code for connecting relay to homebridge, losely based on the on/off screen function in this repo.
2+
# can also be used to control other GPIO based devices
3+
# Usage:
4+
# relay parameters: GPIO pin, timer, reverse on/off, starting state
5+
# bridge.add_accessory(RelaySwitch(38, 0, 0, 1, driver, 'Name', ))
6+
# can also be used with switch characteristic with minor changes.
7+
# feel free to improve
8+
9+
def _gpio_setup(pin):
10+
if GPIO.getmode() is None:
11+
GPIO.setmode(GPIO.BOARD)
12+
GPIO.setup(pin, GPIO.OUT)
13+
14+
15+
def set_gpio_state(pin, state, reverse):
16+
if state:
17+
if reverse:
18+
GPIO.output(pin, 1)
19+
else:
20+
GPIO.output(pin, 0)
21+
else:
22+
if reverse:
23+
GPIO.output(pin, 0)
24+
else:
25+
GPIO.output(pin, 1)
26+
#logging.info("Setting pin: %s to state: %s", pin, state)
27+
28+
29+
def get_gpio_state(pin, reverse):
30+
if GPIO.input(pin):
31+
if reverse:
32+
return int(1)
33+
else:
34+
return int(0)
35+
else:
36+
if reverse:
37+
return int(0)
38+
else:
39+
return int(1)
40+
41+
42+
class RelaySwitch(Accessory):
43+
category = CATEGORY_OUTLET
44+
45+
def __init__(self, pin_number, counter, reverse, startstate, *args, **kwargs):
46+
super().__init__(*args, **kwargs)
47+
48+
self.pin_number = pin_number
49+
self.counter = counter
50+
self.reverse = reverse
51+
self.startstate = startstate
52+
53+
_gpio_setup(self.pin_number)
54+
55+
serv_switch = self.add_preload_service('Outlet')
56+
self.relay_on = serv_switch.configure_char(
57+
'On', setter_callback=self.set_relay)
58+
59+
self.relay_in_use = serv_switch.configure_char(
60+
'OutletInUse', setter_callback=self.get_relay_in_use)
61+
62+
self.timer = 1
63+
64+
self.set_relay(startstate)
65+
66+
@Accessory.run_at_interval(1)
67+
def run(self):
68+
state = get_gpio_state(self.pin_number, self.reverse)
69+
70+
if self.relay_on.value != state:
71+
self.relay_on.value = state
72+
self.relay_on.notify()
73+
self.relay_in_use.notify()
74+
75+
oldstate = 1
76+
77+
if state != oldstate:
78+
self.timer = 1
79+
oldstate == state
80+
81+
if self.timer == self.counter:
82+
set_gpio_state(self.pin_number, 0, self.reverse)
83+
self.timer = 1
84+
85+
self.timer = self.timer + 1
86+
#logging.info("counter %s state is %s", self.timer, state)
87+
88+
89+
def set_relay(self, state):
90+
if get_gpio_state(self.pin_number, self.reverse) != state:
91+
if state:
92+
set_gpio_state(self.pin_number, 1, self.reverse)
93+
else:
94+
set_gpio_state(self.pin_number, 0, self.reverse)
95+
96+
def get_relay_in_use(self, state):
97+
return True
98+

pyhap/accessory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ async def stop(self):
294294

295295
# Driver
296296

297-
def publish(self, value, sender):
297+
def publish(self, value, sender, sender_client_addr=None):
298298
"""Append AID and IID of the sender and forward it to the driver.
299299
300300
Characteristics call this method to send updates.
@@ -310,7 +310,7 @@ def publish(self, value, sender):
310310
HAP_REPR_IID: self.iid_manager.get_iid(sender),
311311
HAP_REPR_VALUE: value,
312312
}
313-
self.driver.publish(acc_data)
313+
self.driver.publish(acc_data, sender_client_addr)
314314

315315

316316
class Bridge(Accessory):

pyhap/accessory_driver.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ def subscribe_client_topic(self, client, topic, subscribe=True):
405405
if not subscribed_clients:
406406
del self.topics[topic]
407407

408-
def publish(self, data):
408+
def publish(self, data, sender_client_addr=None):
409409
"""Publishes an event to the client.
410410
411411
The publishing occurs only if the current client is subscribed to the topic for
@@ -421,7 +421,7 @@ def publish(self, data):
421421

422422
data = {HAP_REPR_CHARS: [data]}
423423
bytedata = json.dumps(data).encode()
424-
self.event_queue.put((topic, bytedata))
424+
self.event_queue.put((topic, bytedata, sender_client_addr))
425425

426426
def send_events(self):
427427
"""Start sending events from the queue to clients.
@@ -440,10 +440,18 @@ def send_events(self):
440440
while not self.loop.is_closed():
441441
# Maybe consider having a pool of worker threads, each performing a send in
442442
# order to increase throughput.
443-
topic, bytedata = self.event_queue.get()
443+
#
444+
# Clients that made the characteristic change are NOT susposed to get events
445+
# about the characteristic change as it can cause an HTTP disconnect and violates
446+
# the HAP spec
447+
#
448+
topic, bytedata, sender_client_addr = self.event_queue.get()
444449
subscribed_clients = self.topics.get(topic, [])
445-
logger.debug('Send event: topic(%s), data(%s)', topic, bytedata)
450+
logger.debug('Send event: topic(%s), data(%s), sender_client_addr(%s)', topic, bytedata, sender_client_addr)
446451
for client_addr in subscribed_clients.copy():
452+
if sender_client_addr and sender_client_addr == client_addr:
453+
logger.debug('Skip sending event to client since its the client that made the characteristic change: %s', client_addr)
454+
continue
447455
logger.debug('Sending event to client: %s', client_addr)
448456
pushed = self.http_server.push_event(bytedata, client_addr)
449457
if not pushed:
@@ -638,7 +646,7 @@ def set_characteristics(self, chars_query, client_addr):
638646

639647
if HAP_REPR_VALUE in cq:
640648
# TODO: status needs to be based on success of set_value
641-
char.client_update_value(cq[HAP_REPR_VALUE])
649+
char.client_update_value(cq[HAP_REPR_VALUE], client_addr)
642650

643651
def signal_handler(self, _signal, _frame):
644652
"""Stops the AccessoryDriver for a given signal.

pyhap/characteristic.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,26 +200,26 @@ def set_value(self, value, should_notify=True):
200200
if should_notify and self.broker:
201201
self.notify()
202202

203-
def client_update_value(self, value):
203+
def client_update_value(self, value, sender_client_addr=None):
204204
"""Called from broker for value change in Home app.
205205
206206
Change self.value to value and call callback.
207207
"""
208-
logger.debug('client_update_value: %s to %s',
209-
self.display_name, value)
208+
logger.debug('client_update_value: %s to %s from client: %s',
209+
self.display_name, value, sender_client_addr)
210210
self.value = value
211-
self.notify()
211+
self.notify(sender_client_addr)
212212
if self.setter_callback:
213213
# pylint: disable=not-callable
214214
self.setter_callback(value)
215215

216-
def notify(self):
216+
def notify(self, sender_client_addr=None):
217217
"""Notify clients about a value change. Sends the value.
218218
219219
.. seealso:: accessory.publish
220220
.. seealso:: accessory_driver.publish
221221
"""
222-
self.broker.publish(self.value, self)
222+
self.broker.publish(self.value, self, sender_client_addr)
223223

224224
# pylint: disable=invalid-name
225225
def to_HAP(self):

pyhap/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""This module contains constants used by other modules."""
22
MAJOR_VERSION = 2
3-
MINOR_VERSION = 6
3+
MINOR_VERSION = 7
44
PATCH_VERSION = 0
55
__short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION)
66
__version__ = '{}.{}'.format(__short_version__, PATCH_VERSION)

pyhap/hap_server.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import pyhap.tlv as tlv
2626
from pyhap.util import long_to_bytes
27+
from pyhap.const import __version__
2728

2829
logger = logging.getLogger(__name__)
2930

@@ -137,6 +138,13 @@ def __init__(self, sock, client_addr, server, accessory_handler):
137138
self.state = self.accessory_handler.state
138139
self.enc_context = None
139140
self.is_encrypted = False
141+
self.server_version = 'pyhap/' + __version__
142+
# HTTP/1.1 allows a keep-alive which makes
143+
# a large accessory list usable in homekit
144+
# If iOS has to reconnect to query each accessory
145+
# it can be painfully slow and lead to lock up on the
146+
# client side as well as non-responsive devices
147+
self.protocol_version = 'HTTP/1.1'
140148
# Redirect separate handlers to the dispatch method
141149
self.do_GET = self.do_POST = self.do_PUT = self.dispatch
142150

@@ -190,9 +198,24 @@ def _upgrade_to_encrypted(self):
190198
def end_response(self, bytesdata, close_connection=False):
191199
"""Combines adding a length header and actually sending the data."""
192200
self.send_header("Content-Length", len(bytesdata))
193-
self.end_headers()
194-
self.wfile.write(bytesdata)
195-
self.close_connection = 1 if close_connection else 0
201+
# Setting this head will take care of setting
202+
# self.close_connection to the right value
203+
self.send_header("Connection", ("close" if close_connection else "keep-alive"))
204+
# Important: we need to send the headers and the
205+
# content in a single write to avoid homekit
206+
# on the client side stalling and making
207+
# devices appear non-responsive.
208+
#
209+
# The below code does what end_headers does internally
210+
# except it combines the headers and the content
211+
# into a single write instead of two calls to
212+
# self.wfile.write
213+
#
214+
# TODO: Is there a better way that doesn't involve
215+
# touching _headers_buffer ?
216+
#
217+
self.connection.sendall(b"".join(self._headers_buffer) + b"\r\n" + bytesdata)
218+
self._headers_buffer = []
196219

197220
def dispatch(self):
198221
"""Dispatch the request to the appropriate handler method."""
@@ -204,6 +227,7 @@ def dispatch(self):
204227
getattr(self, self.HANDLERS[self.command][path])()
205228
except NotAllowedInStateException:
206229
self.send_response(403)
230+
self.end_response(b'')
207231
except UnprivilegedRequestException:
208232
response = {"status": HAP_SERVER_STATUS.INSUFFICIENT_PRIVILEGES}
209233
data = json.dumps(response).encode("utf-8")
@@ -364,6 +388,7 @@ def _pairing_five(self, client_username, client_ltpk, encryption_key):
364388

365389
if not should_confirm:
366390
self.send_response(500)
391+
self.end_response(b'')
367392
return
368393

369394
tlv_data = tlv.encode(HAP_TLV_TAGS.SEQUENCE_NUM, b'\x06',
@@ -523,6 +548,7 @@ def handle_set_characteristics(self):
523548
except Exception as e:
524549
logger.exception('Exception in set_characteristics: %s', e)
525550
self.send_response(HTTPStatus.BAD_REQUEST)
551+
self.end_response(b'')
526552
else:
527553
self.send_response(HTTPStatus.NO_CONTENT)
528554
self.end_response(b'')
@@ -552,6 +578,7 @@ def _handle_add_pairing(self, tlv_objects):
552578
client_uuid, client_public)
553579
if not should_confirm:
554580
self.send_response(500)
581+
self.end_response(b'')
555582
return
556583

557584
data = tlv.encode(HAP_TLV_TAGS.SEQUENCE_NUM, b"\x02")

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class MockDriver():
1616
def __init__(self):
1717
self.loader = Loader()
1818

19-
def publish(self, data):
19+
def publish(self, data, client_addr=None):
2020
pass
2121

2222
def add_job(self, target, *args):

tests/test_accessory_driver.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,34 @@ def setup_message(self):
7575
driver.add_accessory(acc)
7676
driver.start()
7777
assert driver.loop.is_closed()
78+
79+
80+
def test_send_events(driver):
81+
class LoopMock():
82+
runcount = 0
83+
84+
def is_closed(self):
85+
self.runcount += 1
86+
if self.runcount > 1:
87+
return True
88+
return False
89+
90+
class HapServerMock():
91+
pushed_events = []
92+
93+
def push_event(self, bytedata, client_addr):
94+
self.pushed_events.extend([[bytedata, client_addr]])
95+
return 1
96+
97+
def get_pushed_events(self):
98+
return self.pushed_events
99+
100+
driver.http_server = HapServerMock()
101+
driver.loop = LoopMock()
102+
driver.topics = {"mocktopic": ["client1", "client2", "client3"]}
103+
driver.event_queue.put(("mocktopic", "bytedata", "client1"))
104+
driver.send_events()
105+
106+
# Only client2 and client3 get the event when client1 sent it
107+
assert (driver.http_server.get_pushed_events() ==
108+
[["bytedata", "client2"], ["bytedata", "client3"]])

0 commit comments

Comments
 (0)