Skip to content

Commit

Permalink
Merge pull request #377 from ikalchev/v4.1.0
Browse files Browse the repository at this point in the history
V4.1.0
  • Loading branch information
ikalchev committed Aug 22, 2021
2 parents f1a9784 + 7d7d34f commit 6e88070
Show file tree
Hide file tree
Showing 17 changed files with 760 additions and 144 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ Sections
### Developers
-->

## [4.1.0] - 2021-08-22

### Added
- Add support for saving permissions when pairing. [#372](https://github.com/ikalchev/HAP-python/pull/372)
- Add accessory-level callbacks. [#373](https://github.com/ikalchev/HAP-python/pull/373)

### Changed
- Increment the config version when the accessory changes. [#376](https://github.com/ikalchev/HAP-python/pull/376)

## [4.0.0] - 2021-07-22

- Add support for HAP v 1.1. [#365](https://github.com/ikalchev/HAP-python/pull/365)
Expand Down
1 change: 1 addition & 0 deletions pyhap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
try:
import base36 # noqa: F401
import pyqrcode # noqa: F401

SUPPORT_QR_CODE = True
except ImportError:
pass
4 changes: 2 additions & 2 deletions pyhap/accessory.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def __init__(self, driver, display_name, aid=None):
self.driver = driver
self.services = []
self.iid_manager = IIDManager()
self.setter_callback = None

self.add_info_service()
if aid == STANDALONE_AID:
Expand Down Expand Up @@ -90,8 +91,7 @@ def add_info_service(self):
def add_protocol_version_service(self):
"""Helper method to add the required HAP Protocol Information service"""
serv_hap_proto_info = Service(
HAP_PROTOCOL_INFORMATION_SERVICE_UUID,
"HAPProtocolInformation"
HAP_PROTOCOL_INFORMATION_SERVICE_UUID, "HAPProtocolInformation"
)
serv_hap_proto_info.add_characteristic(self.driver.loader.get_char("Version"))
serv_hap_proto_info.configure_char("Version", value=HAP_PROTOCOL_VERSION)
Expand Down
184 changes: 120 additions & 64 deletions pyhap/accessory_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@
HAP_REPR_PID,
HAP_REPR_STATUS,
HAP_REPR_VALUE,
MAX_CONFIG_VERSION,
STANDALONE_AID,
)
from pyhap.encoder import AccessoryEncoder
Expand All @@ -69,6 +68,55 @@
DASH_REGEX = re.compile(r"[-]+")


def _wrap_char_setter(char, value, client_addr):
"""Process an characteristic setter callback trapping and logging all exceptions."""
try:
char.client_update_value(value, client_addr)
except Exception: # pylint: disable=broad-except
logger.exception(
"%s: Error while setting characteristic %s to %s",
client_addr,
char.display_name,
value,
)
return HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE
return HAP_SERVER_STATUS.SUCCESS


def _wrap_acc_setter(acc, updates_by_service, client_addr):
"""Process an accessory setter callback trapping and logging all exceptions."""
try:
acc.setter_callback(updates_by_service)
except Exception: # pylint: disable=broad-except
logger.exception(
"%s: Error while setting characteristics to %s for the %s accessory",
updates_by_service,
client_addr,
acc,
)
return HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE
return HAP_SERVER_STATUS.SUCCESS


def _wrap_service_setter(service, chars, client_addr):
"""Process a service setter callback trapping and logging all exceptions."""
# Ideally this would pass the chars as is without converting
# them to the display_name, but that would break existing
# consumers of the data.
service_chars = {char.display_name: value for char, value in chars.items()}
try:
service.setter_callback(service_chars)
except Exception: # pylint: disable=broad-except
logger.exception(
"%s: Error while setting characteristics to %s for the %s service",
service_chars,
client_addr,
service.display_name,
)
return HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE
return HAP_SERVER_STATUS.SUCCESS


class AccessoryMDNSServiceInfo(ServiceInfo):
"""A mDNS service info representation of an accessory."""

Expand Down Expand Up @@ -265,8 +313,10 @@ def start(self):
"""
try:
logger.info("Starting the event loop")
if threading.current_thread() is threading.main_thread() \
and os.name != "nt":
if (
threading.current_thread() is threading.main_thread()
and os.name != "nt"
):
logger.debug("Setting child watcher")
watcher = asyncio.SafeChildWatcher()
watcher.attach_loop(self.loop)
Expand Down Expand Up @@ -325,6 +375,13 @@ async def async_start(self):
logger.debug("Starting server.")
await self.http_server.async_start(self.loop)

# Update the hash of the accessories
# in case the config version needs to be
# incremented to tell iOS to drop the cache
# for /accessories
if self.state.set_accessories_hash(self.accessories_hash):
self.async_persist()

# Advertise the accessory as a mDNS service.
logger.debug("Starting mDNS.")
self.mdns_service_info = AccessoryMDNSServiceInfo(self.accessory, self.state)
Expand Down Expand Up @@ -519,7 +576,9 @@ def async_send_event(self, topic, data, sender_client_addr, immediate):
client_addr,
)
continue
logger.debug("Sending event to client: %s, immediate: %s", client_addr, immediate)
logger.debug(
"Sending event to client: %s, immediate: %s", client_addr, immediate
)
pushed = self.http_server.push_event(data, client_addr, immediate)
if not pushed:
logger.debug(
Expand All @@ -538,9 +597,7 @@ def config_changed(self):
restart. Also, updates the mDNS advertisement, so that iOS clients know they need
to fetch new data.
"""
self.state.config_version += 1
if self.state.config_version > MAX_CONFIG_VERSION:
self.state.config_version = 1
self.state.increment_config_version()
self.persist()
self.update_advertisement()

Expand Down Expand Up @@ -589,11 +646,11 @@ def load(self):
Must run in executor.
"""
with open(self.persist_file, "r") as file_handle:
with open(self.persist_file, "r", encoding="utf8") as file_handle:
self.encoder.load_into(file_handle, self.state)

@callback
def pair(self, client_uuid, client_public):
def pair(self, client_uuid, client_public, client_permissions):
"""Called when a client has paired with the accessory.
Persist the new accessory state.
Expand All @@ -604,11 +661,14 @@ def pair(self, client_uuid, client_public):
:param client_public: The client's public key.
:type client_public: bytes
:param client_permissions: The client's permissions.
:type client_permissions: bytes (int)
:return: Whether the pairing is successful.
:rtype: bool
"""
logger.info("Paired with %s.", client_uuid)
self.state.add_paired_client(client_uuid, client_public)
self.state.add_paired_client(client_uuid, client_public, client_permissions)
self.async_persist()
return True

Expand Down Expand Up @@ -652,6 +712,13 @@ def setup_srp_verifier(self):
verifier = SrpServer(ctx, b"Pair-Setup", self.state.pincode)
self.srp_verifier = verifier

@property
def accessories_hash(self):
"""Hash the get_accessories response to track configuration changes."""
return hashlib.sha512(
util.to_sorted_hap_json(self.get_accessories())
).hexdigest()

def get_accessories(self):
"""Returns the accessory in HAP format.
Expand Down Expand Up @@ -758,7 +825,7 @@ def set_characteristics(self, chars_query, client_addr):
:type chars_query: dict
"""
# TODO: Add support for chars that do no support notifications.
accessory_callbacks = {}
updates = {}
setter_results = {}
had_error = False
expired = False
Expand All @@ -771,11 +838,10 @@ def set_characteristics(self, chars_query, client_addr):

for cq in chars_query[HAP_REPR_CHARS]:
aid, iid = cq[HAP_REPR_AID], cq[HAP_REPR_IID]
result = setter_results.setdefault(aid, {})
char = self.accessory.get_characteristic(aid, iid)
setter_results.setdefault(aid, {})

if expired:
result[iid] = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST
setter_results[aid][iid] = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST
had_error = True
continue

Expand All @@ -792,62 +858,50 @@ def set_characteristics(self, chars_query, client_addr):
if HAP_REPR_VALUE not in cq:
continue

value = cq[HAP_REPR_VALUE]
updates.setdefault(aid, {})[iid] = cq[HAP_REPR_VALUE]

try:
char.client_update_value(value, client_addr)
except Exception: # pylint: disable=broad-except
logger.exception(
"%s: Error while setting characteristic %s to %s",
client_addr,
char.display_name,
value,
)
result[iid] = HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE
had_error = True
for aid, new_iid_values in updates.items():
if self.accessory.aid == aid:
acc = self.accessory
else:
result[iid] = HAP_SERVER_STATUS.SUCCESS

# For some services we want to send all the char value
# changes at once. This resolves an issue where we send
# ON and then BRIGHTNESS and the light would go to 100%
# and then dim to the brightness because each callback
# would only send one char at a time.
if not char.service or not char.service.setter_callback:
continue

services = accessory_callbacks.setdefault(aid, {})
acc = self.accessory.accessories.get(aid)

if char.service.display_name not in services:
services[char.service.display_name] = {
SERVICE_CALLBACK: char.service.setter_callback,
SERVICE_CHARS: {},
SERVICE_IIDS: [],
}
updates_by_service = {}
char_to_iid = {}
for iid, value in new_iid_values.items():
# Characteristic level setter callbacks
char = acc.get_characteristic(aid, iid)

service_data = services[char.service.display_name]
service_data[SERVICE_CHARS][char.display_name] = value
service_data[SERVICE_IIDS].append(iid)

for aid, services in accessory_callbacks.items():
for service_name, service_data in services.items():
try:
service_data[SERVICE_CALLBACK](service_data[SERVICE_CHARS])
except Exception: # pylint: disable=broad-except
logger.exception(
"%s: Error while setting characteristics to %s for the %s service",
service_data[SERVICE_CHARS],
client_addr,
service_name,
)
set_result = HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE
set_result = _wrap_char_setter(char, value, client_addr)
if set_result != HAP_SERVER_STATUS.SUCCESS:
had_error = True
else:
set_result = HAP_SERVER_STATUS.SUCCESS

for iid in service_data[SERVICE_IIDS]:
setter_results[aid][iid] = set_result

if not char.service or (
not acc.setter_callback and not char.service.setter_callback
):
continue
char_to_iid[char] = iid
updates_by_service.setdefault(char.service, {}).update({char: value})

# Accessory level setter callbacks
if acc.setter_callback:
set_result = _wrap_acc_setter(acc, updates_by_service, client_addr)
if set_result != HAP_SERVER_STATUS.SUCCESS:
had_error = True
for iid in updates[aid]:
setter_results[aid][iid] = set_result

# Service level setter callbacks
for service, chars in updates_by_service.items():
if not service.setter_callback:
continue
set_result = _wrap_service_setter(service, chars, client_addr)
if set_result != HAP_SERVER_STATUS.SUCCESS:
had_error = True
for char in chars:
setter_results[aid][char_to_iid[char]] = set_result

if not had_error:
return None

Expand Down Expand Up @@ -880,7 +934,9 @@ def prepare(self, prepare_query, client_addr):
try:
ttl = prepare_query[HAP_REPR_TTL]
pid = prepare_query[HAP_REPR_PID]
self.prepared_writes.setdefault(client_addr, {})[pid] = time.time() + (ttl / 1000)
self.prepared_writes.setdefault(client_addr, {})[pid] = time.time() + (
ttl / 1000
)
except (KeyError, ValueError):
return {HAP_REPR_STATUS: HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST}

Expand Down
13 changes: 11 additions & 2 deletions 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 = 4
MINOR_VERSION = 0
MINOR_VERSION = 1
PATCH_VERSION = 0
__short_version__ = "{}.{}".format(MAJOR_VERSION, MINOR_VERSION)
__version__ = "{}.{}".format(__short_version__, PATCH_VERSION)
Expand All @@ -12,7 +12,7 @@
STANDALONE_AID = 1 # Standalone accessory ID (i.e. not bridged)

# ### Default values ###
DEFAULT_CONFIG_VERSION = 2
DEFAULT_CONFIG_VERSION = 1
DEFAULT_PORT = 51827

# ### Configuration version ###
Expand Down Expand Up @@ -97,3 +97,12 @@ class HAP_SERVER_STATUS:
RESOURCE_DOES_NOT_EXIST = -70409
INVALID_VALUE_IN_REQUEST = -70410
INSUFFICIENT_AUTHORIZATION = -70411


class HAP_PERMISSIONS:
USER = b"\x00"
ADMIN = b"\x01"


# Client properties
CLIENT_PROP_PERMS = "permissions"
Loading

0 comments on commit 6e88070

Please sign in to comment.