Skip to content

Commit

Permalink
Merge pull request #224 from ikalchev/v2.7.0
Browse files Browse the repository at this point in the history
V2.7.0
  • Loading branch information
ikalchev authored Jan 26, 2020
2 parents a7424c1 + 856c0fe commit c8ffd07
Show file tree
Hide file tree
Showing 12 changed files with 238 additions and 20 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ Sections
### Developers
-->

## [2.7.0] - 2020-01-26

### Added
- Example Accessory that exposes the raspberry pi GPIO pins as a relay. [#220](https://github.com/ikalchev/HAP-python/pull/220)

### Fixed
- The HAP server is now HTTP version 1.1. [#216](https://github.com/ikalchev/HAP-python/pull/216)
- Fixed an issue where accessories on the server can appear non-responsive. [#216](https://github.com/ikalchev/HAP-python/pull/216)
- Correctly end HAP responses in some error cases. [#217](https://github.com/ikalchev/HAP-python/pull/217)
- 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)

## [2.6.0] - 2019-09-21

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Main features:
* Camera - HAP-python supports the camera accessory from version 2.3.0!
* asyncio support - You can run various tasks or accessories in the event loop.
* Out of the box support for Apple-defined services - see them in [the resources folder](pyhap/resources).
* Secure pairing by just scannig the QR code.
* Secure pairing by just scanning the QR code.
* Integrated with the home automation framework [Home Assistant](https://github.com/home-assistant/home-assistant).

The project was developed for a Raspberry Pi, but it should work on other platforms. To kick-start things,
Expand Down
98 changes: 98 additions & 0 deletions accessories/RPI_Relay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# code for connecting relay to homebridge, losely based on the on/off screen function in this repo.
# can also be used to control other GPIO based devices
# Usage:
# relay parameters: GPIO pin, timer, reverse on/off, starting state
# bridge.add_accessory(RelaySwitch(38, 0, 0, 1, driver, 'Name', ))
# can also be used with switch characteristic with minor changes.
# feel free to improve

def _gpio_setup(pin):
if GPIO.getmode() is None:
GPIO.setmode(GPIO.BOARD)
GPIO.setup(pin, GPIO.OUT)


def set_gpio_state(pin, state, reverse):
if state:
if reverse:
GPIO.output(pin, 1)
else:
GPIO.output(pin, 0)
else:
if reverse:
GPIO.output(pin, 0)
else:
GPIO.output(pin, 1)
#logging.info("Setting pin: %s to state: %s", pin, state)


def get_gpio_state(pin, reverse):
if GPIO.input(pin):
if reverse:
return int(1)
else:
return int(0)
else:
if reverse:
return int(0)
else:
return int(1)


class RelaySwitch(Accessory):
category = CATEGORY_OUTLET

def __init__(self, pin_number, counter, reverse, startstate, *args, **kwargs):
super().__init__(*args, **kwargs)

self.pin_number = pin_number
self.counter = counter
self.reverse = reverse
self.startstate = startstate

_gpio_setup(self.pin_number)

serv_switch = self.add_preload_service('Outlet')
self.relay_on = serv_switch.configure_char(
'On', setter_callback=self.set_relay)

self.relay_in_use = serv_switch.configure_char(
'OutletInUse', setter_callback=self.get_relay_in_use)

self.timer = 1

self.set_relay(startstate)

@Accessory.run_at_interval(1)
def run(self):
state = get_gpio_state(self.pin_number, self.reverse)

if self.relay_on.value != state:
self.relay_on.value = state
self.relay_on.notify()
self.relay_in_use.notify()

oldstate = 1

if state != oldstate:
self.timer = 1
oldstate == state

if self.timer == self.counter:
set_gpio_state(self.pin_number, 0, self.reverse)
self.timer = 1

self.timer = self.timer + 1
#logging.info("counter %s state is %s", self.timer, state)


def set_relay(self, state):
if get_gpio_state(self.pin_number, self.reverse) != state:
if state:
set_gpio_state(self.pin_number, 1, self.reverse)
else:
set_gpio_state(self.pin_number, 0, self.reverse)

def get_relay_in_use(self, state):
return True

4 changes: 2 additions & 2 deletions pyhap/accessory.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ async def stop(self):

# Driver

def publish(self, value, sender):
def publish(self, value, sender, sender_client_addr=None):
"""Append AID and IID of the sender and forward it to the driver.
Characteristics call this method to send updates.
Expand All @@ -310,7 +310,7 @@ def publish(self, value, sender):
HAP_REPR_IID: self.iid_manager.get_iid(sender),
HAP_REPR_VALUE: value,
}
self.driver.publish(acc_data)
self.driver.publish(acc_data, sender_client_addr)


class Bridge(Accessory):
Expand Down
18 changes: 13 additions & 5 deletions pyhap/accessory_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ def subscribe_client_topic(self, client, topic, subscribe=True):
if not subscribed_clients:
del self.topics[topic]

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

data = {HAP_REPR_CHARS: [data]}
bytedata = json.dumps(data).encode()
self.event_queue.put((topic, bytedata))
self.event_queue.put((topic, bytedata, sender_client_addr))

def send_events(self):
"""Start sending events from the queue to clients.
Expand All @@ -440,10 +440,18 @@ def send_events(self):
while not self.loop.is_closed():
# Maybe consider having a pool of worker threads, each performing a send in
# order to increase throughput.
topic, bytedata = self.event_queue.get()
#
# Clients that made the characteristic change are NOT susposed to get events
# about the characteristic change as it can cause an HTTP disconnect and violates
# the HAP spec
#
topic, bytedata, sender_client_addr = self.event_queue.get()
subscribed_clients = self.topics.get(topic, [])
logger.debug('Send event: topic(%s), data(%s)', topic, bytedata)
logger.debug('Send event: topic(%s), data(%s), sender_client_addr(%s)', topic, bytedata, sender_client_addr)
for client_addr in subscribed_clients.copy():
if sender_client_addr and sender_client_addr == client_addr:
logger.debug('Skip sending event to client since its the client that made the characteristic change: %s', client_addr)
continue
logger.debug('Sending event to client: %s', client_addr)
pushed = self.http_server.push_event(bytedata, client_addr)
if not pushed:
Expand Down Expand Up @@ -638,7 +646,7 @@ def set_characteristics(self, chars_query, client_addr):

if HAP_REPR_VALUE in cq:
# TODO: status needs to be based on success of set_value
char.client_update_value(cq[HAP_REPR_VALUE])
char.client_update_value(cq[HAP_REPR_VALUE], client_addr)

def signal_handler(self, _signal, _frame):
"""Stops the AccessoryDriver for a given signal.
Expand Down
12 changes: 6 additions & 6 deletions pyhap/characteristic.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,26 +200,26 @@ def set_value(self, value, should_notify=True):
if should_notify and self.broker:
self.notify()

def client_update_value(self, value):
def client_update_value(self, value, sender_client_addr=None):
"""Called from broker for value change in Home app.
Change self.value to value and call callback.
"""
logger.debug('client_update_value: %s to %s',
self.display_name, value)
logger.debug('client_update_value: %s to %s from client: %s',
self.display_name, value, sender_client_addr)
self.value = value
self.notify()
self.notify(sender_client_addr)
if self.setter_callback:
# pylint: disable=not-callable
self.setter_callback(value)

def notify(self):
def notify(self, sender_client_addr=None):
"""Notify clients about a value change. Sends the value.
.. seealso:: accessory.publish
.. seealso:: accessory_driver.publish
"""
self.broker.publish(self.value, self)
self.broker.publish(self.value, self, sender_client_addr)

# pylint: disable=invalid-name
def to_HAP(self):
Expand Down
2 changes: 1 addition & 1 deletion pyhap/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""This module contains constants used by other modules."""
MAJOR_VERSION = 2
MINOR_VERSION = 6
MINOR_VERSION = 7
PATCH_VERSION = 0
__short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION)
__version__ = '{}.{}'.format(__short_version__, PATCH_VERSION)
Expand Down
33 changes: 30 additions & 3 deletions pyhap/hap_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import pyhap.tlv as tlv
from pyhap.util import long_to_bytes
from pyhap.const import __version__

logger = logging.getLogger(__name__)

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

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

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

if not should_confirm:
self.send_response(500)
self.end_response(b'')
return

tlv_data = tlv.encode(HAP_TLV_TAGS.SEQUENCE_NUM, b'\x06',
Expand Down Expand Up @@ -523,6 +548,7 @@ def handle_set_characteristics(self):
except Exception as e:
logger.exception('Exception in set_characteristics: %s', e)
self.send_response(HTTPStatus.BAD_REQUEST)
self.end_response(b'')
else:
self.send_response(HTTPStatus.NO_CONTENT)
self.end_response(b'')
Expand Down Expand Up @@ -552,6 +578,7 @@ def _handle_add_pairing(self, tlv_objects):
client_uuid, client_public)
if not should_confirm:
self.send_response(500)
self.end_response(b'')
return

data = tlv.encode(HAP_TLV_TAGS.SEQUENCE_NUM, b"\x02")
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class MockDriver():
def __init__(self):
self.loader = Loader()

def publish(self, data):
def publish(self, data, client_addr=None):
pass

def add_job(self, target, *args):
Expand Down
31 changes: 31 additions & 0 deletions tests/test_accessory_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,34 @@ def setup_message(self):
driver.add_accessory(acc)
driver.start()
assert driver.loop.is_closed()


def test_send_events(driver):
class LoopMock():
runcount = 0

def is_closed(self):
self.runcount += 1
if self.runcount > 1:
return True
return False

class HapServerMock():
pushed_events = []

def push_event(self, bytedata, client_addr):
self.pushed_events.extend([[bytedata, client_addr]])
return 1

def get_pushed_events(self):
return self.pushed_events

driver.http_server = HapServerMock()
driver.loop = LoopMock()
driver.topics = {"mocktopic": ["client1", "client2", "client3"]}
driver.event_queue.put(("mocktopic", "bytedata", "client1"))
driver.send_events()

# Only client2 and client3 get the event when client1 sent it
assert (driver.http_server.get_pushed_events() ==
[["bytedata", "client2"], ["bytedata", "client3"]])
11 changes: 10 additions & 1 deletion tests/test_characteristic.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ def test_client_update_value():
assert mock_notify.call_count == 2
mock_callback.assert_called_with(3)

with patch(path_notify) as mock_notify:
char.client_update_value(9, "mock_client_addr")
assert char.value == 9
mock_notify.assert_called_once_with("mock_client_addr")


def test_notify():
"""Test if driver is notified correctly about a changed characteristic."""
Expand All @@ -148,7 +153,11 @@ def test_notify():

with patch.object(char, 'broker') as mock_broker:
char.notify()
mock_broker.publish.assert_called_with(2, char)
mock_broker.publish.assert_called_with(2, char, None)

with patch.object(char, 'broker') as mock_broker:
char.notify("mock_client_addr")
mock_broker.publish.assert_called_with(2, char, "mock_client_addr")


def test_to_HAP_numberic():
Expand Down
Loading

0 comments on commit c8ffd07

Please sign in to comment.