diff --git a/.gitignore b/.gitignore index c10cd35e..d07c2cb1 100644 --- a/.gitignore +++ b/.gitignore @@ -105,4 +105,5 @@ ENV/ /.idea/workspace.xml /.idea/inspectionProfiles/Project_Default.xml /.idea/vcs.xml -/tmp \ No newline at end of file +/tmp +/.idea/homekit_python.iml diff --git a/README.md b/README.md index cd53f03f..d613060d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,65 @@ -# HomeKit client +# HomeKit Python -This code only works with HomeKit IP Accessories. no Bluetooth LE Accessories (yet)! +With this code it is possible to implement either a HomeKit Accessory or simulate a +HomeKit Controller. + +**Limitations** + + * This code only works with HomeKit IP Accessories. no Bluetooth LE Accessories (yet)! + * No reaction to events whatsoever. The code presented in this repository was created based on release R1 from 2017-06-07. +# HomeKit Accessory +This package helps in creating a custom HomeKit Accessory. + +The demonstration uses this JSON in `~/.homekit/demoserver.json`: +```json +{ + "name": "DemoAccessory", + "host_ip": "$YOUR IP", + "host_port": 8080, + "accessory_pairing_id": "12:00:00:00:00:00", + "accessory_pin": "031-45-154", + "peers": {}, + "unsuccessful_tries": 0 +} +``` + +Now let's spawn a simple light bulb accessory as demonstration: + +```python +#!/usr/bin/env python3 + +import os.path + +from homekit import HomeKitServer +from homekit.model import Accessory, LightBulbService + + +if __name__ == '__main__': + try: + httpd = HomeKitServer(os.path.expanduser('~/.homekit/demoserver.json')) + + accessory = Accessory('Licht') + lightService = LightBulbService() + accessory.services.append(lightService) + httpd.accessories.add_accessory(accessory) + + httpd.publish_device() + print('published device and start serving') + httpd.serve_forever() + except KeyboardInterrupt: + print('unpublish device') + httpd.unpublish_device() +``` + +If everything went properly, you should be able to add this accessory to your home on your iOS device. + +# HomeKit Controller + +The following tools help to access HomeKit Accessories. + ## discover.py This tool will list all available HomeKit IP Accessories within the local network. @@ -27,7 +83,7 @@ Status Flags (sf): 0 Category Identifier (ci): Other (Id: 1) ``` -## identfy.py +## identify.py This tool will use the Identify Routine of a HomeKit IP Accessory. @@ -82,6 +138,8 @@ The option `-t` specifies if the type information should be read as well. The option `-e` specifies if the event data should be read as well. +# HomeKit Accessory + # Tests The code was tested with the following devices: diff --git a/demoserver.py b/demoserver.py new file mode 100644 index 00000000..d3d5628c --- /dev/null +++ b/demoserver.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +import os.path + +from homekit import HomeKitServer + +from homekit.model import Accessory, LightBulbService + + +def light_switched(newval): + print('=======> light switched: {x}'.format(x=newval)) + + +if __name__ == '__main__': + try: + httpd = HomeKitServer(os.path.expanduser('~/.homekit/demoserver.json')) + + accessory = Accessory('Testlicht') + lightBulbService = LightBulbService() + lightBulbService.set_on_callback(light_switched) + accessory.services.append(lightBulbService) + httpd.accessories.add_accessory(accessory) + + print(httpd.accessories.__str__()) + + httpd.publish_device() + print('published device and start serving') + httpd.serve_forever() + except KeyboardInterrupt: + print('unpublish device') + httpd.unpublish_device() diff --git a/homekit/__init__.py b/homekit/__init__.py index c1cf10d2..0a553370 100644 --- a/homekit/__init__.py +++ b/homekit/__init__.py @@ -1,22 +1,23 @@ import homekit.feature_flags -import homekit.categories -import homekit.characteristics -import homekit.services +import homekit.model.categories +import homekit.model.services import homekit.statuscodes import homekit.zeroconf -from homekit.tlv import TLV -from homekit.srp import SrpClient from homekit.chacha20poly1305 import chacha20_aead_encrypt, chacha20_aead_decrypt +from homekit.protocol import perform_pair_setup, get_session_keys from homekit.secure_http import SecureHttp +from homekit.server import HomeKitServer +from homekit.srp import SrpClient +from homekit.tlv import TLV from homekit.tools import load_pairing, save_pairing -from homekit.protocol import perform_pair_setup, get_session_keys # Init lookup objects FeatureFlags = homekit.feature_flags.FeatureFlags -Categories = homekit.categories.Categories -StatusCodes = homekit.statuscodes.StatusCodes -CharacteristicsTypes = homekit.characteristics.CharacteristicsTypes -ServicesTypes = homekit.services.ServicesTypes +Categories = homekit.model.categories.Categories +HapStatusCodes = homekit.statuscodes.HapStatusCodes +HttpStatusCodes = homekit.statuscodes.HttpStatusCodes +CharacteristicsTypes = homekit.model.CharacteristicsTypes +ServicesTypes = homekit.model.services.ServicesTypes discover_homekit_devices = homekit.zeroconf.discover_homekit_devices find_device_ip_and_port = homekit.zeroconf.find_device_ip_and_port \ No newline at end of file diff --git a/homekit/characteristics.py b/homekit/characteristics.py deleted file mode 100644 index b430b508..00000000 --- a/homekit/characteristics.py +++ /dev/null @@ -1,141 +0,0 @@ -class _CharacteristicsTypes(object): - """ - This data is taken from Table 12-3 Accessory Categories on page 254. Values above 19 are reserved. - """ - - def __init__(self): - self.baseUUID = '-0000-1000-8000-0026BB765291' - self._characteristics = { - '1': 'public.hap.characteristic.administrator-only-access', - '5': 'public.hap.characteristic.audio-feedback', - '8': 'public.hap.characteristic.brightness', - 'D': 'public.hap.characteristic.temperature.cooling-threshold', - 'E': 'public.hap.characteristic.door-state.current', - 'F': 'public.hap.characteristic.heating-cooling.current', - '10': 'public.hap.characteristic.relative-humidity.current', - '11': 'public.hap.characteristic.temperature.current', - '12': 'public.hap.characteristic.temperature.heating-threshold', - '13': 'public.hap.characteristic.hue', - '14': 'public.hap.characteristic.identify', - '1A': 'public.hap.characteristic.lock-management.auto-secure-timeout', - '1C': 'public.hap.characteristic.lock-mechanism.last-known-action', - '1D': 'public.hap.characteristic.lock-mechanism.current-state', - '1E': 'public.hap.characteristic.lock-mechanism.target-state', - '1F': 'public.hap.characteristic.logs', - '19': 'public.hap.characteristic.lock-management.control-point', - '20': 'public.hap.characteristic.manufacturer', - '21': 'public.hap.characteristic.model', - '22': 'public.hap.characteristic.motion-detected', - '23': 'public.hap.characteristic.name', - '24': 'public.hap.characteristic.obstruction-detected', - '25': 'public.hap.characteristic.on', - '26': 'public.hap.characteristic.outlet-in-use', - '28': 'public.hap.characteristic.rotation.direction', - '29': 'public.hap.characteristic.rotation.speed', - '2F': 'public.hap.characteristic.saturation', - '30': 'public.hap.characteristic.serial-number', - '32': 'public.hap.characteristic.door-state.target', - '33': 'public.hap.characteristic.heating-cooling.target', - '34': 'public.hap.characteristic.relative-humidity.target', - '35': 'public.hap.characteristic.temperature.target', - '36': 'public.hap.characteristic.temperature.units', - '37': 'public.hap.characteristic.version', - '52': 'public.hap.characteristic.firmware.revision', - '53': 'public.hap.characteristic.hardware.revision', - '64': 'public.hap.characteristic.air-particulate.density', - '65': 'public.hap.characteristic.air-particulate.size', - '66': 'public.hap.characteristic.security-system-state.current', - '67': 'public.hap.characteristic.security-system-state.target', - '68': 'public.hap.characteristic.battery-level', - '69': 'public.hap.characteristic.carbon-monoxide.detected', - '6A': 'public.hap.characteristic.contact-state', - '6B': 'public.hap.characteristic.light-level.current', - '6C': 'public.hap.characteristic.horizontal-tilt.current', - '6D': 'public.hap.characteristic.position.current', - '6E': 'public.hap.characteristic.vertical-tilt.current', - '6F': 'public.hap.characteristic.position.hold', - '70': 'public.hap.characteristic.leak-detected', - '71': 'public.hap.characteristic.occupancy-detected', - '72': 'public.hap.characteristic.position.state', - '73': 'public.hap.characteristic.input-event', - '75': 'public.hap.characteristic.status-active', - '76': 'public.hap.characteristic.smoke-detected', - '77': 'public.hap.characteristic.status-fault', - '78': 'public.hap.characteristic.status-jammed', - '79': 'public.hap.characteristic.status-lo-batt', - '7A': 'public.hap.characteristic.status-tampered', - '7B': 'public.hap.characteristic.horizontal-tilt.target', - '7C': 'public.hap.characteristic.position.target', - '7D': 'public.hap.characteristic.vertical-tilt.target', - '8E': 'public.hap.characteristic.security-system.alarm-type', - '8F': 'public.hap.characteristic.charging-state', - '90': 'public.hap.characteristic.carbon-monoxide.level', - '91': 'public.hap.characteristic.carbon-monoxide.peak-level', - '92': 'public.hap.characteristic.carbon-dioxide.detected', - '93': 'public.hap.characteristic.carbon-dioxide.level', - '94': 'public.hap.characteristic.carbon-dioxide.peak-level', - '95': 'public.hap.characteristic.air-quality', - 'A6': 'public.hap.characteristic.accessory-properties', - 'A7': 'public.hap.characteristic.lock-physical-controls', - 'A8': 'public.hap.characteristic.air-purifier.state.target', - 'A9': 'public.hap.characteristic.air-purifier.state.current', - 'AA': 'public.hap.characteristic.slat.state.current', - 'AB': 'public.hap.characteristic.filter.life-level', - 'AC': 'public.hap.characteristic.filter.change-indication', - 'AD': 'public.hap.characteristic.filter.reset-indication', - 'AF': 'public.hap.characteristic.fan.state.current', - 'B0': 'public.hap.characteristic.active', - 'B6': 'public.hap.characteristic.swing-mode', - 'BF': 'public.hap.characteristic.fan.state.target', - 'C0': 'public.hap.characteristic.type.slat', - 'C1': 'public.hap.characteristic.tilt.current', - 'C2': 'public.hap.characteristic.tilt.target', - 'C3': 'public.hap.characteristic.density.ozone', - 'C4': 'public.hap.characteristic.density.no2', - 'C5': 'public.hap.characteristic.density.so2', - 'C6': 'public.hap.characteristic.density.pm25', - 'C7': 'public.hap.characteristic.density.pm10', - 'C8': 'public.hap.characteristic.density.voc', - 'CB': 'public.hap.characteristic.service-label-index', - 'CD': 'public.hap.characteristic.service-label-namespace', - 'CE': 'public.hap.characteristic.color-temperature', - '114': 'public.hap.characteristic.supported-video-stream-configuration', - '115': 'public.hap.characteristic.supported-audio-configuration', - '116': 'public.hap.characteristic.supported-rtp-configuration', - '117': 'public.hap.characteristic.selected-rtp-stream-configuration', - '118': 'public.hap.characteristic.setup-endpoints', - '119': 'public.hap.characteristic.volume', - '11A': 'public.hap.characteristic.mute', - '11B': 'public.hap.characteristic.night-vision', - '11C': 'public.hap.characteristic.zoom-optical', - '11D': 'public.hap.characteristic.zoom-digital', - '11E': 'public.hap.characteristic.image-rotation', - '11F': 'public.hap.characteristic.image-mirror', - '120': 'public.hap.characteristic.streaming-status', - } - - self._characteristics_rev = {self._characteristics[k]: k for k in self._characteristics.keys()} - - def __getitem__(self, item): - if item in self._characteristics: - return self._characteristics[item] - - if item in self._characteristics_rev: - return self._characteristics_rev[item] - - # raise KeyError('Item {item} not found'.format_map(item=item)) - return 'Unknown Characteristic {i}?'.format(i=item) - - def get_short(self, item: str): - orig_item = item - if item.endswith(self.baseUUID): - item = item.split('-', 1)[0] - item = item.lstrip('0') - - if item in self._characteristics: - return self._characteristics[item].split('.')[-1] - - return 'Unknown Characteristic {i}?'.format(i=orig_item) - - -CharacteristicsTypes = _CharacteristicsTypes() diff --git a/discover.py b/homekit/discover.py similarity index 100% rename from discover.py rename to homekit/discover.py diff --git a/get_accessories.py b/homekit/get_accessories.py similarity index 100% rename from get_accessories.py rename to homekit/get_accessories.py diff --git a/get_characteristic.py b/homekit/get_characteristic.py similarity index 100% rename from get_characteristic.py rename to homekit/get_characteristic.py diff --git a/identify.py b/homekit/identify.py similarity index 87% rename from identify.py rename to homekit/identify.py index f55acd01..1fc5858d 100755 --- a/identify.py +++ b/homekit/identify.py @@ -4,7 +4,7 @@ import argparse import http.client -from homekit import find_device_ip_and_port, StatusCodes +from homekit import find_device_ip_and_port, HapStatusCodes def setup_args_parser(): @@ -26,7 +26,7 @@ def setup_args_parser(): if resp.code == 400: data = json.loads(resp.read().decode()) code = data['status'] - print('identify failed because: {reason} ({code}). Is it paired?'.format(reason=StatusCodes[code], code=code)) + print('identify failed because: {reason} ({code}). Is it paired?'.format(reason=HapStatusCodes[code], code=code)) elif resp.code == 200: print('identify succeeded.') conn.close() diff --git a/homekit/model/__init__.py b/homekit/model/__init__.py new file mode 100644 index 00000000..a06844e5 --- /dev/null +++ b/homekit/model/__init__.py @@ -0,0 +1,27 @@ +from homekit.model.characteristics import CharacteristicsTypes +from homekit.model.mixin import ToDictMixin, get_id +from homekit.model.services import AcessoryInformationService, LightBulbService, OutletService, FanService, \ + ThermostatService +from homekit.model.categories import Categories + + +class Accessory(ToDictMixin): + def __init__(self, name): + self.aid = get_id() + self.services = [ + AcessoryInformationService(name) + ] + + def get_name(self): + for service in self.services: + if isinstance(service, AcessoryInformationService): + return service.get_name() + return None + + +class Accessories(ToDictMixin): + def __init__(self): + self.accessories = [] + + def add_accessory(self, acessory: Accessory): + self.accessories.append(acessory) diff --git a/homekit/categories.py b/homekit/model/categories.py similarity index 100% rename from homekit/categories.py rename to homekit/model/categories.py diff --git a/homekit/model/characteristics.py b/homekit/model/characteristics.py new file mode 100644 index 00000000..a8b140a1 --- /dev/null +++ b/homekit/model/characteristics.py @@ -0,0 +1,511 @@ +from homekit.model.mixin import ToDictMixin, get_id + + +class _CharacteristicsTypes(object): + """ + This data is taken from Table 12-3 Accessory Categories on page 254. Values above 19 are reserved. + """ + ACCESSORY_PROPERTIES = 'A6' + ACTIVE = 'B0' + ADMINISTRATOR_ONLY_ACCESS = '1' + AIR_PARTICULATE_DENSITY = '64' + AIR_PARTICULATE_SIZE = '65' + AIR_PURIFIER_STATE_CURRENT = 'A9' + AIR_PURIFIER_STATE_TARGET = 'A8' + AIR_QUALITY = '95' + AUDIO_FEEDBACK = '5' + BATTERY_LEVEL = '68' + BRIGHTNESS = '8' + CARBON_DIOXIDE_DETECTED = '92' + CARBON_DIOXIDE_LEVEL = '93' + CARBON_DIOXIDE_PEAK_LEVEL = '94' + CARBON_MONOXIDE_DETECTED = '69' + CARBON_MONOXIDE_LEVEL = '90' + CARBON_MONOXIDE_PEAK_LEVEL = '91' + CHARGING_STATE = '8F' + COLOR_TEMPERATURE = 'CE' + CONTACT_STATE = '6A' + DENSITY_NO2 = 'C4' + DENSITY_OZONE = 'C3' + DENSITY_PM10 = 'C7' + DENSITY_PM25 = 'C6' + DENSITY_SO2 = 'C5' + DENSITY_VOC = 'C8' + DOOR_STATE_CURRENT = 'E' + DOOR_STATE_TARGET = '32' + FAN_STATE_CURRENT = 'AF' + FAN_STATE_TARGET = 'BF' + FILTER_CHANGE_INDICATION = 'AC' + FILTER_LIFE_LEVEL = 'AB' + FILTER_RESET_INDICATION = 'AD' + FIRMWARE_REVISION = '52' + HARDWARE_REVISION = '53' + HEATING_COOLING_CURRENT = 'F' + HEATING_COOLING_TARGET = '33' + HORIZONTAL_TILT_CURRENT = '6C' + HORIZONTAL_TILT_TARGET = '7B' + HUE = '13' + IDENTIFY = '14' + IMAGE_MIRROR = '11F' + IMAGE_ROTATION = '11E' + INPUT_EVENT = '73' + LEAK_DETECTED = '70' + LIGHT_LEVEL_CURRENT = '6B' + LOCK_MANAGEMENT_AUTO_SECURE_TIMEOUT = '1A' + LOCK_MANAGEMENT_CONTROL_POINT = '19' + LOCK_MECHANISM_CURRENT_STATE = '1D' + LOCK_MECHANISM_LAST_KNOWN_ACTION = '1C' + LOCK_MECHANISM_TARGET_STATE = '1E' + LOCK_PHYSICAL_CONTROLS = 'A7' + LOGS = '1F' + MANUFACTURER = '20' + MODEL = '21' + MOTION_DETECTED = '22' + MUTE = '11A' + NAME = '23' + NIGHT_VISION = '11B' + OBSTRUCTION_DETECTED = '24' + OCCUPANCY_DETECTED = '71' + ON = '25' + OUTLET_IN_USE = '26' + POSITION_CURRENT = '6D' + POSITION_HOLD = '6F' + POSITION_STATE = '72' + POSITION_TARGET = '7C' + RELATIVE_HUMIDITY_CURRENT = '10' + RELATIVE_HUMIDITY_TARGET = '34' + ROTATION_DIRECTION = '28' + ROTATION_SPEED = '29' + SATURATION = '2F' + SECURITY_SYSTEM_ALARM_TYPE = '8E' + SECURITY_SYSTEM_STATE_CURRENT = '66' + SECURITY_SYSTEM_STATE_TARGET = '67' + SELECTED_RTP_STREAM_CONFIGURATION = '117' + SERIAL_NUMBER = '30' + SERVICE_LABEL_INDEX = 'CB' + SERVICE_LABEL_NAMESPACE = 'CD' + SETUP_ENDPOINTS = '118' + SLAT_STATE_CURRENT = 'AA' + SMOKE_DETECTED = '76' + STATUS_ACTIVE = '75' + STATUS_FAULT = '77' + STATUS_JAMMED = '78' + STATUS_LO_BATT = '79' + STATUS_TAMPERED = '7A' + STREAMING_STATUS = '120' + SUPPORTED_AUDIO_CONFIGURATION = '115' + SUPPORTED_RTP_CONFIGURATION = '116' + SUPPORTED_VIDEO_STREAM_CONFIGURATION = '114' + SWING_MODE = 'B6' + TEMPERATURE_COOLING_THRESHOLD = 'D' + TEMPERATURE_CURRENT = '11' + TEMPERATURE_HEATING_THRESHOLD = '12' + TEMPERATURE_TARGET = '35' + TEMPERATURE_UNITS = '36' + TILT_CURRENT = 'C1' + TILT_TARGET = 'C2' + TYPE_SLAT = 'C0' + VERSION = '37' + VERTICAL_TILT_CURRENT = '6E' + VERTICAL_TILT_TARGET = '7D' + VOLUME = '119' + ZOOM_DIGITAL = '11D' + ZOOM_OPTICAL = '11C' + + def __init__(self): + self.baseUUID = '-0000-1000-8000-0026BB765291' + self._characteristics = { + '1': 'public.hap.characteristic.administrator-only-access', + '5': 'public.hap.characteristic.audio-feedback', + '8': 'public.hap.characteristic.brightness', + 'D': 'public.hap.characteristic.temperature.cooling-threshold', + 'E': 'public.hap.characteristic.door-state.current', + 'F': 'public.hap.characteristic.heating-cooling.current', + '10': 'public.hap.characteristic.relative-humidity.current', + '11': 'public.hap.characteristic.temperature.current', + '12': 'public.hap.characteristic.temperature.heating-threshold', + '13': 'public.hap.characteristic.hue', + '14': 'public.hap.characteristic.identify', + '1A': 'public.hap.characteristic.lock-management.auto-secure-timeout', + '1C': 'public.hap.characteristic.lock-mechanism.last-known-action', + '1D': 'public.hap.characteristic.lock-mechanism.current-state', + '1E': 'public.hap.characteristic.lock-mechanism.target-state', + '1F': 'public.hap.characteristic.logs', + '19': 'public.hap.characteristic.lock-management.control-point', + '20': 'public.hap.characteristic.manufacturer', + '21': 'public.hap.characteristic.model', + '22': 'public.hap.characteristic.motion-detected', + '23': 'public.hap.characteristic.name', + '24': 'public.hap.characteristic.obstruction-detected', + '25': 'public.hap.characteristic.on', + '26': 'public.hap.characteristic.outlet-in-use', + '28': 'public.hap.characteristic.rotation.direction', + '29': 'public.hap.characteristic.rotation.speed', + '2F': 'public.hap.characteristic.saturation', + '30': 'public.hap.characteristic.serial-number', + '32': 'public.hap.characteristic.door-state.target', + '33': 'public.hap.characteristic.heating-cooling.target', + '34': 'public.hap.characteristic.relative-humidity.target', + '35': 'public.hap.characteristic.temperature.target', + '36': 'public.hap.characteristic.temperature.units', + '37': 'public.hap.characteristic.version', + '52': 'public.hap.characteristic.firmware.revision', + '53': 'public.hap.characteristic.hardware.revision', + '64': 'public.hap.characteristic.air-particulate.density', + '65': 'public.hap.characteristic.air-particulate.size', + '66': 'public.hap.characteristic.security-system-state.current', + '67': 'public.hap.characteristic.security-system-state.target', + '68': 'public.hap.characteristic.battery-level', + '69': 'public.hap.characteristic.carbon-monoxide.detected', + '6A': 'public.hap.characteristic.contact-state', + '6B': 'public.hap.characteristic.light-level.current', + '6C': 'public.hap.characteristic.horizontal-tilt.current', + '6D': 'public.hap.characteristic.position.current', + '6E': 'public.hap.characteristic.vertical-tilt.current', + '6F': 'public.hap.characteristic.position.hold', + '70': 'public.hap.characteristic.leak-detected', + '71': 'public.hap.characteristic.occupancy-detected', + '72': 'public.hap.characteristic.position.state', + '73': 'public.hap.characteristic.input-event', + '75': 'public.hap.characteristic.status-active', + '76': 'public.hap.characteristic.smoke-detected', + '77': 'public.hap.characteristic.status-fault', + '78': 'public.hap.characteristic.status-jammed', + '79': 'public.hap.characteristic.status-lo-batt', + '7A': 'public.hap.characteristic.status-tampered', + '7B': 'public.hap.characteristic.horizontal-tilt.target', + '7C': 'public.hap.characteristic.position.target', + '7D': 'public.hap.characteristic.vertical-tilt.target', + '8E': 'public.hap.characteristic.security-system.alarm-type', + '8F': 'public.hap.characteristic.charging-state', + '90': 'public.hap.characteristic.carbon-monoxide.level', + '91': 'public.hap.characteristic.carbon-monoxide.peak-level', + '92': 'public.hap.characteristic.carbon-dioxide.detected', + '93': 'public.hap.characteristic.carbon-dioxide.level', + '94': 'public.hap.characteristic.carbon-dioxide.peak-level', + '95': 'public.hap.characteristic.air-quality', + 'A6': 'public.hap.characteristic.accessory-properties', + 'A7': 'public.hap.characteristic.lock-physical-controls', + 'A8': 'public.hap.characteristic.air-purifier.state.target', + 'A9': 'public.hap.characteristic.air-purifier.state.current', + 'AA': 'public.hap.characteristic.slat.state.current', + 'AB': 'public.hap.characteristic.filter.life-level', + 'AC': 'public.hap.characteristic.filter.change-indication', + 'AD': 'public.hap.characteristic.filter.reset-indication', + 'AF': 'public.hap.characteristic.fan.state.current', + 'B0': 'public.hap.characteristic.active', + 'B6': 'public.hap.characteristic.swing-mode', + 'BF': 'public.hap.characteristic.fan.state.target', + 'C0': 'public.hap.characteristic.type.slat', + 'C1': 'public.hap.characteristic.tilt.current', + 'C2': 'public.hap.characteristic.tilt.target', + 'C3': 'public.hap.characteristic.density.ozone', + 'C4': 'public.hap.characteristic.density.no2', + 'C5': 'public.hap.characteristic.density.so2', + 'C6': 'public.hap.characteristic.density.pm25', + 'C7': 'public.hap.characteristic.density.pm10', + 'C8': 'public.hap.characteristic.density.voc', + 'CB': 'public.hap.characteristic.service-label-index', + 'CD': 'public.hap.characteristic.service-label-namespace', + 'CE': 'public.hap.characteristic.color-temperature', + '114': 'public.hap.characteristic.supported-video-stream-configuration', + '115': 'public.hap.characteristic.supported-audio-configuration', + '116': 'public.hap.characteristic.supported-rtp-configuration', + '117': 'public.hap.characteristic.selected-rtp-stream-configuration', + '118': 'public.hap.characteristic.setup-endpoints', + '119': 'public.hap.characteristic.volume', + '11A': 'public.hap.characteristic.mute', + '11B': 'public.hap.characteristic.night-vision', + '11C': 'public.hap.characteristic.zoom-optical', + '11D': 'public.hap.characteristic.zoom-digital', + '11E': 'public.hap.characteristic.image-rotation', + '11F': 'public.hap.characteristic.image-mirror', + '120': 'public.hap.characteristic.streaming-status', + } + + self._characteristics_rev = {self._characteristics[k]: k for k in self._characteristics.keys()} + + def __getitem__(self, item): + if item in self._characteristics: + return self._characteristics[item] + + if item in self._characteristics_rev: + return self._characteristics_rev[item] + + # raise KeyError('Item {item} not found'.format_map(item=item)) + return 'Unknown Characteristic {i}?'.format(i=item) + + def get_short(self, item: str): + orig_item = item + if item.endswith(self.baseUUID): + item = item.split('-', 1)[0] + item = item.lstrip('0') + + if item in self._characteristics: + return self._characteristics[item].split('.')[-1] + + return 'Unknown Characteristic {i}?'.format(i=orig_item) + + def get_uuid(self, item_name): + if item_name in self._characteristics_rev: + short = self._characteristics_rev[item_name] + if item_name in self._characteristics: + short = item_name + medium = '0' * (8 - len(short)) + short + long = medium + self.baseUUID + return long + + +CharacteristicsTypes = _CharacteristicsTypes() + + +class CharacteristicUnits(object): + """ + See table 5-6 page 68 + """ + celsius = 'celsius' + percentage = 'percentage' + arcdegrees = 'arcdegrees' + lux = 'lux' + seconds = 'seconds' + + +class CharacteristicPermissions(object): + """ + See table 5-4 page 67 + """ + paired_read = 'pr' + paired_write = 'pw' + events = 'ev' + addition_authorization = 'aa' + timed_write = 'tw' + hidden = 'hd' + + +class CharacteristicFormats(object): + """ + Values for characteristic's format taken from table 5-5 page 67 + """ + bool = 'bool' + uint8 = 'uint8' + uint16 = 'uint16' + uint32 = 'uint32' + uint64 = 'uint64' + int = 'int' + float = 'float' + string = 'string' + tlv8 = 'tlv8' + data = 'data' + + +class Characteristic(ToDictMixin): + def __init__(self, iid: int, characteristic_type: str, characteristic_format: str): + self.type = CharacteristicsTypes.get_uuid(characteristic_type) + self.iid = iid + self.value = None + self.perms = [CharacteristicPermissions.paired_read] + self.ev = None # not required + self.description = None # string, not required + self.format = characteristic_format + self.unit = None # string, not required + self.minValue = None # number, not required + self.maxValue = None # number, not required + self.minStep = None # number, not required + self.maxLen = None # number, not required + self.maxDataLen = None # number, not required + self.valid_values = None # array, not required + self.valid_values_range = None # array, not required + self._set_value_callback = None + self._get_value_callback = None + + def set_set_value_callback(self, callback): + self._set_value_callback = callback + + def set_get_value_callback(self, callback): + self._get_value_callback = callback + + def set_events(self, new_val): + self.ev = new_val + + def set_value(self, new_val): + self.value = new_val + if self._set_value_callback: + self._set_value_callback(new_val) + + def get_value(self): + if self._get_value_callback: + return self._get_value_callback() + return self.value + + +class CurrentHeatingCoolingStateCharacteristic(Characteristic): + """ + Defined on page 147 + """ + + def __init__(self, iid): + Characteristic.__init__(self, iid, CharacteristicsTypes.HEATING_COOLING_CURRENT, CharacteristicFormats.uint8) + self.perms = [CharacteristicPermissions.paired_read, CharacteristicPermissions.events] + self.minValue = 0 + self.maxValue = 2 + self.step = 1 + self.value = 0 + + +class CurrentTemperatureCharacteristic(Characteristic): + """ + Defined on page 148 + """ + + def __init__(self, iid): + Characteristic.__init__(self, iid, CharacteristicsTypes.TEMPERATURE_CURRENT, CharacteristicFormats.float) + self.perms = [CharacteristicPermissions.paired_read, CharacteristicPermissions.events] + self.minValue = 0.0 + self.maxValue = 100.0 + self.step = 0.1 + self.unit = CharacteristicUnits.celsius + self.value = 23.0 + + +class TargetHeatingCoolingStateCharacteristic(Characteristic): + """ + Defined on page 161 + """ + + def __init__(self, iid): + Characteristic.__init__(self, iid, CharacteristicsTypes.HEATING_COOLING_TARGET, CharacteristicFormats.uint8) + self.perms = [CharacteristicPermissions.paired_write, CharacteristicPermissions.paired_read, + CharacteristicPermissions.events] + self.minValue = 0 + self.maxValue = 3 + self.step = 1 + self.value = 0 + + +class TargetTemperatureCharacteristic(Characteristic): + """ + Defined on page 162 + """ + + def __init__(self, iid): + Characteristic.__init__(self, iid, CharacteristicsTypes.TEMPERATURE_TARGET, CharacteristicFormats.float) + self.perms = [CharacteristicPermissions.paired_write, CharacteristicPermissions.paired_read, + CharacteristicPermissions.events] + self.minValue = 10.0 + self.maxValue = 38.0 + self.step = 0.1 + self.unit = CharacteristicUnits.celsius + self.value = 23.0 + + +class TemperatureDisplayUnits(Characteristic): + """ + Defined on page 163 + """ + + def __init__(self, iid): + Characteristic.__init__(self, iid, CharacteristicsTypes.TEMPERATURE_UNITS, CharacteristicFormats.uint8) + self.perms = [CharacteristicPermissions.paired_write, CharacteristicPermissions.paired_read, + CharacteristicPermissions.events] + self.minValue = 0 + self.maxValue = 1 + self.step = 1 + self.value = 0 + + +class FirmwareRevisionCharacteristic(Characteristic): + """ + Defined on page 149 + """ + + def __init__(self, iid, revision): + Characteristic.__init__(self, iid, CharacteristicsTypes.FIRMWARE_REVISION, CharacteristicFormats.string) + self.value = revision + self.description = 'Firmware Revision' + + +class IdentifyCharacteristic(Characteristic): + """ + Defined on page 152 + """ + + def __init__(self, iid): + Characteristic.__init__(self, iid, CharacteristicsTypes.IDENTIFY, CharacteristicFormats.bool) + self.perms = [CharacteristicPermissions.paired_write] + self.description = 'Identify' + + def set_value(self, new_val): + pass + + +class ManufacturerCharacteristic(Characteristic): + """ + Defined on page 156 + """ + + def __init__(self, iid, manufacturer): + Characteristic.__init__(self, iid, CharacteristicsTypes.MANUFACTURER, CharacteristicFormats.string) + self.value = manufacturer + self.maxLen = 64 + self.description = 'Manufacturer' + + +class ModelCharacteristic(Characteristic): + """ + Defined on page 156 + """ + + def __init__(self, iid, model): + Characteristic.__init__(self, iid, CharacteristicsTypes.MODEL, CharacteristicFormats.string) + self.value = model + self.maxLen = 64 + self.description = 'Model' + + +class NameCharacteristic(Characteristic): + """ + Defined on page 157 + """ + + def __init__(self, iid, name): + Characteristic.__init__(self, iid, CharacteristicsTypes.NAME, CharacteristicFormats.string) + self.value = name + self.maxLen = 64 + self.description = 'Name' + + +class OnCharacteristic(Characteristic): + """ + Defined on page 157 + """ + + def __init__(self, iid): + Characteristic.__init__(self, iid, CharacteristicsTypes.ON, CharacteristicFormats.bool) + self.description = 'On' + self.perms = [CharacteristicPermissions.paired_write, CharacteristicPermissions.paired_read, + CharacteristicPermissions.events] + self.value = False + + +class OutletInUseCharacteristic(Characteristic): + """ + Defined on page 158 + """ + + def __init__(self, iid): + Characteristic.__init__(self, iid, CharacteristicsTypes.OUTLET_IN_USE, CharacteristicFormats.bool) + self.description = 'Outlet in use' + self.perms = [CharacteristicPermissions.paired_read, CharacteristicPermissions.events] + self.value = False + + +class SerialNumberCharacteristic(Characteristic): + """ + Defined on page 160 + """ + + def __init__(self, iid, number): + Characteristic.__init__(self, iid, CharacteristicsTypes.SERIAL_NUMBER, CharacteristicFormats.string) + self.value = number + self.maxLen = 64 + self.description = 'Serial Number' diff --git a/homekit/model/mixin.py b/homekit/model/mixin.py new file mode 100644 index 00000000..cd840678 --- /dev/null +++ b/homekit/model/mixin.py @@ -0,0 +1,40 @@ +import json + +id_counter = 0 + + +def get_id(): + global id_counter + id_counter += 1 + return id_counter + + +class ToDictMixin(object): + """ + Will help to convert the various accessories, services and characteristics to JSON. + """ + + def _to_dict(self): + tmp = {} + for x in dir(self): + if x.startswith('_') or callable(getattr(self, x)): + continue + val = getattr(self, x) + if val is None: + continue + if isinstance(val, list): + tmpval = [] + for e in val: + if isinstance(e, str): + tmpval.append(e) + else: + tmpval.append(e._to_dict()) + tmp[x] = tmpval + else: + tmp[x] = val + + return tmp + + def __str__(self): + d = self._to_dict() + return json.dumps(d) diff --git a/homekit/model/services.py b/homekit/model/services.py new file mode 100644 index 00000000..7ba49010 --- /dev/null +++ b/homekit/model/services.py @@ -0,0 +1,191 @@ +from homekit.model.characteristics import * + + +class _ServicesTypes(object): + """ + This data is taken from Table 12-3 Accessory Categories on page 254. Values above 19 are reserved. + """ + + def __init__(self): + self.baseUUID = '-0000-1000-8000-0026BB765291' + self._services = { + '3E': 'public.hap.service.accessory-information', + '40': 'public.hap.service.fan', + '41': 'public.hap.service.garage-door-opener', + '43': 'public.hap.service.lightbulb', + '44': 'public.hap.service.lock-management', + '45': 'public.hap.service.lock-mechanism', + '47': 'public.hap.service.outlet', + '49': 'public.hap.service.switch', + '4A': 'public.hap.service.thermostat', + '7E': 'public.hap.service.security-system', + '7F': 'public.hap.service.sensor.carbon-monoxide', + '80': 'public.hap.service.sensor.contact', + '81': 'public.hap.service.door', + '82': 'public.hap.service.sensor.humidity', + '83': 'public.hap.service.sensor.leak', + '84': 'public.hap.service.sensor.light', + '85': 'public.hap.service.sensor.motion', + '86': 'public.hap.service.sensor.occupancy', + '87': 'public.hap.service.sensor.smoke', + '89': 'public.hap.service.stateless-programmable-switch', + '8A': 'public.hap.service.sensor.temperature', + '8B': 'public.hap.service.window', + '8C': 'public.hap.service.window-covering', + '8D': 'public.hap.service.sensor.air-quality', + '96': 'public.hap.service.battery', + '97': 'public.hap.service.sensor.carbon-dioxide', + 'B7': 'public.hap.service.fanv2', + 'B9': 'public.hap.service.vertical-slat', + 'BA': 'public.hap.service.filter-maintenance', + 'BB': 'public.hap.service.air-purifier', + 'CC': 'public.hap.service.service-label', + '110': 'public.hap.service.camera-rtp-stream-management', + '112': 'public.hap.service.microphone', + '113': 'public.hap.service.speaker', + '121': 'public.hap.service.doorbell', + } + + self._services_rev = {self._services[k]: k for k in self._services.keys()} + + def __getitem__(self, item): + if item in self._services: + return self._services[item] + + if item in self._services_rev: + return self._services_rev[item] + + # raise KeyError('Item {item} not found'.format_map(item=item)) + return 'Unknown Service: {i}'.format(i=item) + + def get_short(self, item): + orig_item = item + if item.endswith(self.baseUUID): + item = item.split('-', 1)[0] + item = item.lstrip('0') + + if item in self._services: + return self._services[item].split('.')[-1] + return 'Unknown Service: {i}'.format(i=orig_item) + + def get_uuid(self, item_name): + if item_name not in self._services_rev: + raise Exception('Unknown service name') + short = self._services_rev[item_name] + medium = '0' * (8 - len(short)) + short + long = medium + self.baseUUID + return long + + +ServicesTypes = _ServicesTypes() + + +class _Service(ToDictMixin): + def __init__(self, service_type: str, iid: int): + self.type = service_type + self.iid = iid + self.characteristics = [] + pass + + +class AcessoryInformationService(_Service): + """ + Defined on page 216 + """ + + def __init__(self, name): + _Service.__init__(self, ServicesTypes.get_uuid('public.hap.service.accessory-information'), get_id()) + self.characteristics.append(IdentifyCharacteristic(get_id())) + self.characteristics.append(ManufacturerCharacteristic(get_id(), 'lusiardi.de')) + self.characteristics.append(ModelCharacteristic(get_id(), 'python bridge')) + self.characteristics.append(NameCharacteristic(get_id(), name)) + self.characteristics.append(SerialNumberCharacteristic(get_id(), '1')) + self.characteristics.append(FirmwareRevisionCharacteristic(get_id(), '0.1')) + + def get_name(self): + for characteristic in self.characteristics: + if isinstance(characteristic, NameCharacteristic): + return characteristic.value + return None + + +class FanService(_Service): + """ + Defined on page 216 + """ + + def __init__(self): + _Service.__init__(self, ServicesTypes.get_uuid('public.hap.service.fan'), get_id()) + self._onCharacteristic = OnCharacteristic(get_id()) + self.characteristics.append(self._onCharacteristic) + + def set_on_set_callback(self, callback): + self._onCharacteristic.set_set_value_callback(callback) + + def set_on_get_callback(self, callback): + self._onCharacteristic.set_get_value_callback(callback) + + +class LightBulbService(_Service): + """ + Defined on page 217 + """ + + def __init__(self): + _Service.__init__(self, ServicesTypes.get_uuid('public.hap.service.lightbulb'), get_id()) + self._onCharacteristic = OnCharacteristic(get_id()) + self.characteristics.append(self._onCharacteristic) + + def set_on_set_callback(self, callback): + self._onCharacteristic.set_set_value_callback(callback) + + def set_on_get_callback(self, callback): + self._onCharacteristic.set_get_value_callback(callback) + + +class OutletService(_Service): + """ + Defined on page 219 + """ + + def __init__(self): + _Service.__init__(self, ServicesTypes.get_uuid('public.hap.service.outlet'), get_id()) + self._onCharacteristic = OnCharacteristic(get_id()) + self.characteristics.append(self._onCharacteristic) + self._outletInUse = OutletInUseCharacteristic(get_id()) + self.characteristics.append(self._outletInUse) + + def set_on_callback(self, callback): + self._onCharacteristic.set_set_value_callback(callback) + + +class ThermostatService(_Service): + """ + Defined on page 220 + """ + + def __init__(self): + _Service.__init__(self, ServicesTypes.get_uuid('public.hap.service.thermostat'), get_id()) + + self._currentHeatingCoolingState = CurrentHeatingCoolingStateCharacteristic(get_id()) + self.characteristics.append(self._currentHeatingCoolingState) + + self._targetHeatingCoolingState = TargetHeatingCoolingStateCharacteristic(get_id()) + self.characteristics.append(self._targetHeatingCoolingState) + + self._currentTemperature = CurrentTemperatureCharacteristic(get_id()) + self.characteristics.append(self._currentTemperature) + + self._targetTemperature = TargetTemperatureCharacteristic(get_id()) + self.characteristics.append(self._targetTemperature) + + self._temperatureDisplayUnits = TemperatureDisplayUnits(get_id()) + self.characteristics.append(self._temperatureDisplayUnits) + + def set_target_temperature(self, callback): + self._targetTemperature.set_set_value_callback(callback) + + def set_target_heating_cooling_state(self, callback): + self._targetHeatingCoolingState.set_set_value_callback(callback) + + diff --git a/pair.py b/homekit/pair.py old mode 100644 new mode 100755 similarity index 97% rename from pair.py rename to homekit/pair.py index 0768d576..6476d6db --- a/pair.py +++ b/homekit/pair.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import argparse import http.client import uuid diff --git a/homekit/put_characteristic.py b/homekit/put_characteristic.py new file mode 100755 index 00000000..c70c5828 --- /dev/null +++ b/homekit/put_characteristic.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +import json +import argparse +import http.client +import sys + +from homekit import find_device_ip_and_port, SecureHttp, load_pairing, get_session_keys, HapStatusCodes + + +def setup_args_parser(): + parser = argparse.ArgumentParser(description='HomeKit perform app - performs operations on paired devices') + parser.add_argument('-f', action='store', required=True, dest='file', help='File with the pairing data') + parser.add_argument('-c', action='store', required=False, dest='characteristics') + parser.add_argument('-v', action='store', required=False, dest='value') + + return parser + + +if __name__ == '__main__': + parser = setup_args_parser() + args = parser.parse_args() + + pairing_data = load_pairing(args.file) + if pairing_data is None: + print('File {file} not found!'.format(file=args.file)) + sys.exit(-1) + + deviceId = pairing_data['AccessoryPairingID'] + + connection_data = find_device_ip_and_port(deviceId) + if connection_data is None: + print('Device {id} not found'.format(id=deviceId)) + sys.exit(-1) + + conn = http.client.HTTPConnection(connection_data['ip'], port=connection_data['port']) + pairing_data = load_pairing(args.file) + + controllerToAccessoryKey, accessoryToControllerKey = get_session_keys(conn, pairing_data) + + if not args.characteristics: + parser.print_help() + sys.exit(-1) + if not args.value: + parser.print_help() + sys.exit(-1) + + tmp = args.characteristics.split('.') + aid = int(tmp[0]) + iid = int(tmp[1]) + value = args.value + + sec_http = SecureHttp(conn.sock, accessoryToControllerKey, controllerToAccessoryKey) + + body = json.dumps({'characteristics': [{'aid': aid, 'iid': iid, 'value': value}]}) + print(body) + response = sec_http.put('/characteristics', body) + data = response.read().decode() + if response.code != 204: + data = json.loads(data) + code = data['status'] + print('put_characteristics failed because: {reason} ({code})'.format(reason=HapStatusCodes[code], code=code)) + else: + print('put_characteristics succeeded') + + conn.close() diff --git a/homekit/request_handler.py b/homekit/request_handler.py new file mode 100644 index 00000000..3c0843b5 --- /dev/null +++ b/homekit/request_handler.py @@ -0,0 +1,759 @@ +from http.server import BaseHTTPRequestHandler +import binascii +import io +import json +import gmpy2 +import py25519 +import hkdf +import hashlib + +from homekit.tlv import TLV +from homekit.srp import SrpServer +from homekit.chacha20poly1305 import chacha20_aead_encrypt, chacha20_aead_decrypt +from homekit.statuscodes import HttpStatusCodes +from homekit.statuscodes import HapStatusCodes + + +def bytes_to_mpz(input_bytes): + return gmpy2.mpz(binascii.hexlify(input_bytes), 16) + + +class HomeKitRequestHandler(BaseHTTPRequestHandler): + VALID_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE'] + + def __init__(self, request, client_address, server): + self.debug_crypt = False + self.debug_pair_verify = False + self.debug_put_characteristics = True + self.debug_get_characteristics = False + + # keep pycharm from complaining about those not being define in __init__ + # self.session_id = '{ip}:{port}'.format(ip=client_address[0], port= client_address[1]) + self.session_id = '{ip}'.format(ip=client_address[0]) + if self.session_id not in server.sessions: + server.sessions[self.session_id] = {} + self.rfile = None + self.wfile = None + self.body = None + self.PATHMAPPING = { + '/accessories': { + 'GET': self._get_accessories + }, + '/characteristics': { + 'GET': self._get_characteristics, + 'PUT': self._put_characteristics + }, + '/identify': { + 'POST': self._post_identify + }, + '/pair-setup': { + 'POST': self._post_pair_setup + }, + '/pair-verify': { + 'POST': self._post_pair_verify + }, + '/pairings': { + 'POST': self._post_pairings + } + } + self.protocol_version = 'HTTP/1.1' + + # init super class + BaseHTTPRequestHandler.__init__(self, request, client_address, server) + + def handle_one_request(self): + """ + This is used to determine wether the request is encrypted or not. This is done by looking at the first bytes of + the request. To be valid unencrypted HTTP call, it must be one of the methods defined in RFC7231 Section 4 + "Request Methods". + :return: + """ + try: + raw_peeked_data = self.rfile.peek(10) + if len(raw_peeked_data) == 0: + return + # RFC7230 Section 3 tells us, that US-ASCII is fine + peeked_data = raw_peeked_data[:10] + peeked_data = peeked_data.decode(encoding='ASCII') + # self.log_message('deciding over: >%s<', peeked_data) + # If the request line starts with a known HTTP verb, then use handle_one_request from super class + if ' ' in peeked_data: + method = peeked_data.split(' ', 1)[0] + if method in self.VALID_METHODS: + self.server.sessions[self.session_id]['enrypted_connection'] = False + BaseHTTPRequestHandler.handle_one_request(self) + return + except UnicodeDecodeError as e: + # self.log_error('exception %s' % e) + pass + + # the first 2 bytes are the length of the encrypted data to follow + len_bytes = self.rfile.read(2) + data_len = int.from_bytes(len_bytes, byteorder='little') + + # the authtag is not counted, so add its length + data = self.rfile.read(data_len + 16) + if self.debug_crypt: + self.log_message('data >%i< >%s<', len(data), binascii.hexlify(data)) + + # get the crypto key from the session + c2a_key = self.server.sessions[self.session_id]['controller_to_accessory_key'] + + # verify & decrypt the read data + cnt_bytes = self.server.sessions[self.session_id]['controller_to_accessory_count'].to_bytes(8, + byteorder='little') + decrypted = chacha20_aead_decrypt(len_bytes, c2a_key, cnt_bytes, bytes([0, 0, 0, 0]), + data) + if decrypted == False: + self.log_error('Could not decrypt %s', binascii.hexlify(data)) + # TODO: handle errors + pass + + if self.debug_crypt: + self.log_message('crypted request >%s<', decrypted) + + self.server.sessions[self.session_id]['controller_to_accessory_count'] += 1 + + # replace the original rfile with a fake with the decrypted stuff + old_rfile = self.rfile + self.rfile = io.BytesIO(decrypted) + + # replace writefile to pass on encrypted data + old_wfile = self.wfile + self.wfile = io.BytesIO() + + # call known function + self.server.sessions[self.session_id]['enrypted_connection'] = True + BaseHTTPRequestHandler.handle_one_request(self) + + # read the plaintext and send it out encrypted + self.wfile.seek(0) + in_data = self.wfile.read(65537) + + if self.debug_crypt: + self.log_message('response >%s<', in_data) + self.log_message('len(response) %s', len(in_data)) + + block_size = 1024 + out_data = bytearray() + while len(in_data) > 0: + block = in_data[:block_size] + if self.debug_crypt: + self.log_message('==> BLOCK: len %s', len(block)) + in_data = in_data[block_size:] + + len_bytes = len(block).to_bytes(2, byteorder='little') + a2c_key = self.server.sessions[self.session_id]['accessory_to_controller_key'] + cnt_bytes = self.server.sessions[self.session_id]['accessory_to_controller_count'].to_bytes(8, + byteorder='little') + ciper_and_mac = chacha20_aead_encrypt(len_bytes, a2c_key, cnt_bytes, bytes([0, 0, 0, 0]), block) + self.server.sessions[self.session_id]['accessory_to_controller_count'] += 1 + out_data += len_bytes + ciper_and_mac[0] + ciper_and_mac[1] + + # change back to originals to handle multiple calls + self.rfile = old_rfile + self.wfile = old_wfile + + # send data to original requester + self.wfile.write(out_data) + self.wfile.flush() + + def _get_characteristics(self): + """ + As described on page 84 + :return: + """ + if self.debug_get_characteristics: + self.log_message('GET /characteristics') + + # analyse + params = {} + if '?' in self.path: + params = {t.split('=')[0]: t.split('=')[1] for t in self.path.split('?')[1].split('&')} + + # handle id param + ids = [] + if 'id' in params: + ids = params['id'].split(',') + + # handle meta param + meta = False + if 'meta' in params: + meta = params['meta'] == 1 + + # handle perms param + perms = False + if 'perms' in params: + perms = params['perms'] == 1 + + # handle type param + type = False + if 'type' in params: + type = params['type'] == 1 + + # handle ev param + ev = False + if 'ev' in params: + ev = params['ev'] == 1 + + if self.debug_get_characteristics: + self.log_message('query parameters: ids: %s, meta: %s, perms: %s, type: %s, ev: %s', ids, meta, perms, type, ev) + + result = { + 'characteristics': [] + } + + for id_pair in ids: + id_pair = id_pair.split('.') + aid = int(id_pair[0]) + cid = int(id_pair[1]) + for accessory in self.server.accessories.accessories: + if accessory.aid != aid: + continue + for service in accessory.services: + for characteristic in service.characteristics: + if characteristic.iid != cid: + continue + result['characteristics'].append({'aid': aid, 'iid': cid, 'value': characteristic.get_value()}) + if self.debug_get_characteristics: + self.log_message('chars: %s', json.dumps(result)) + + result_bytes = json.dumps(result).encode() + + self.send_response(HttpStatusCodes.OK) + self.send_header('Content-Type', 'application/hap+json') + self.send_header('Content-Length', len(result_bytes)) + self.end_headers() + self.wfile.write(result_bytes) + + def _put_characteristics(self): + """ + Defined page 80 ff + :return: + """ + if self.debug_put_characteristics: + self.log_message('PUT /characteristics') + self.log_message('body: %s', self.body) + + data = json.loads(self.body.decode()) + characteristics_to_set = data['characteristics'] + for characteristic_to_set in characteristics_to_set: + aid = characteristic_to_set['aid'] + cid = characteristic_to_set['iid'] + + for accessory in self.server.accessories.accessories: + if accessory.aid != aid: + continue + for service in accessory.services: + for characteristic in service.characteristics: + if characteristic.iid != cid: + continue + + if 'ev' in characteristic_to_set: + if self.debug_put_characteristics: + self.log_message('set ev >%s< >%s< >%s<', aid, cid, characteristic_to_set['ev']) + characteristic.set_events(characteristic_to_set['ev']) + + if 'value' in characteristic_to_set: + if self.debug_put_characteristics: + self.log_message('set value >%s< >%s< >%s<', aid, cid, characteristic_to_set['value']) + characteristic.set_value(characteristic_to_set['value']) + + self.send_response(HttpStatusCodes.NO_CONTENT) + self.end_headers() + + def _post_identify(self): + if self.server.data.is_paired: + result_bytes = json.dumps({'status': HapStatusCodes.INSUFFICIENT_PRIVILEGES}).encode() + self.send_response(HttpStatusCodes.BAD_REQUEST) + self.send_header('Content-Type', 'application/hap+json') + self.send_header('Content-Length', len(result_bytes)) + self.end_headers() + self.wfile.write(result_bytes) + else: + # perform identify action + # send status code + self.send_response(HttpStatusCodes.NO_CONTENT) + self.end_headers() + + def _get_accessories(self): + + result_bytes = self.server.accessories.__str__().encode() + self.send_response(HttpStatusCodes.OK) + self.send_header('Content-Type', 'application/hap+json') + self.send_header('Content-Length', len(result_bytes)) + self.end_headers() + self.wfile.write(result_bytes) + + def _post_pair_verify(self): + d_req = TLV.decode_bytes(self.body) + + d_res = {} + + if d_req[TLV.kTLVType_State] == TLV.M1: + # step #2 Accessory -> iOS Device Verify Start Response + if self.debug_pair_verify: + self.log_message('Step #2 /pair-verify') + + # 1) generate new curve25519 key pair + accessory_session_key = py25519.Key25519() + accessory_spk = accessory_session_key.public_key().pubkey + self.server.sessions[self.session_id]['accessory_pub_key'] = accessory_spk + + # 2) generate shared secret + ios_device_curve25519_pub_key_bytes = d_req[TLV.kTLVType_PublicKey] + self.server.sessions[self.session_id]['ios_device_pub_key'] = ios_device_curve25519_pub_key_bytes + ios_device_curve25519_pub_key = py25519.Key25519(pubkey=bytes(ios_device_curve25519_pub_key_bytes), + verifyingkey=bytes()) + shared_secret = accessory_session_key.get_ecdh_key(ios_device_curve25519_pub_key) + self.server.sessions[self.session_id]['shared_secret'] = shared_secret + + # 3) generate accessory info + accessory_info = accessory_spk + self.server.data.accessory_pairing_id_bytes + \ + ios_device_curve25519_pub_key_bytes + + # 4) sign accessory info for accessory signature + accessory_ltsk = py25519.Key25519(secretkey=self.server.data.accessory_ltsk) + accessory_signature = accessory_ltsk.sign(accessory_info) + + # 5) sub tlv + sub_tlv = { + TLV.kTLVType_Identifier: self.server.data.accessory_pairing_id_bytes, + TLV.kTLVType_Signature: accessory_signature + } + sub_tlv_b = TLV.encode_dict(sub_tlv) + + # 6) derive session key + hkdf_inst = hkdf.Hkdf('Pair-Verify-Encrypt-Salt'.encode(), shared_secret, hash=hashlib.sha512) + session_key = hkdf_inst.expand('Pair-Verify-Encrypt-Info'.encode(), 32) + self.server.sessions[self.session_id]['session_key'] = session_key + + # 7) encrypt sub tlv + encrypted_data_with_auth_tag = chacha20_aead_encrypt(bytes(), + session_key, + 'PV-Msg02'.encode(), + bytes([0, 0, 0, 0]), + sub_tlv_b) + tmp = bytearray(encrypted_data_with_auth_tag[0]) + tmp += encrypted_data_with_auth_tag[1] + + # 8) construct result tlv + d_res[TLV.kTLVType_State] = TLV.M2 + d_res[TLV.kTLVType_PublicKey] = accessory_spk + d_res[TLV.kTLVType_EncryptedData] = tmp + + self._send_response_tlv(d_res) + if self.debug_pair_verify: + self.log_message('after step #2\n%s', TLV.to_string(d_res)) + return + + if d_req[TLV.kTLVType_State] == TLV.M3: + # step #4 Accessory -> iOS Device Verify Finish Response + if self.debug_pair_verify: + self.log_message('Step #4 /pair-verify') + + session_key = self.server.sessions[self.session_id]['session_key'] + + # 1) verify ios' authtag + # 2) decrypt + encrypted = d_req[TLV.kTLVType_EncryptedData] + decrypted = chacha20_aead_decrypt(bytes(), session_key, 'PV-Msg03'.encode(), bytes([0, 0, 0, 0]), + encrypted) + if decrypted == False: + self.send_error_reply(TLV.M4, TLV.kTLVError_Authentication) + print('error in step #4: authtag', d_res, self.server.sessions) + return + d1 = TLV.decode_bytes(decrypted) + assert TLV.kTLVType_Identifier in d1 + assert TLV.kTLVType_Signature in d1 + + # 3) get ios_device_ltpk + ios_device_pairing_id = d1[TLV.kTLVType_Identifier] + self.server.sessions[self.session_id]['ios_device_pairing_id'] = ios_device_pairing_id + ios_device_ltpk_bytes = self.server.data.get_peer_key(ios_device_pairing_id) + if ios_device_ltpk_bytes is None: + self.send_error_reply(TLV.M4, TLV.kTLVError_Authentication) + print('error in step #4: not paired', d_res, self.server.sessions) + return + ios_device_ltpk = py25519.Key25519(pubkey=bytes(), verifyingkey=ios_device_ltpk_bytes) + + # 4) verify ios_device_info + ios_device_sig = d1[TLV.kTLVType_Signature] + ios_device_curve25519_pub_key_bytes = self.server.sessions[self.session_id]['ios_device_pub_key'] + accessory_spk = self.server.sessions[self.session_id]['accessory_pub_key'] + ios_device_info = ios_device_curve25519_pub_key_bytes + ios_device_pairing_id + accessory_spk + if not ios_device_ltpk.verify(bytes(ios_device_sig), bytes(ios_device_info)): + self.send_error_reply(TLV.M4, TLV.kTLVError_Authentication) + print('error in step #4: signature', d_res, self.server.sessions) + return + + # + shared_secret = self.server.sessions[self.session_id]['shared_secret'] + hkdf_inst = hkdf.Hkdf('Control-Salt'.encode(), shared_secret, hash=hashlib.sha512) + controller_to_accessory_key = hkdf_inst.expand('Control-Write-Encryption-Key'.encode(), 32) + self.server.sessions[self.session_id]['controller_to_accessory_key'] = controller_to_accessory_key + self.server.sessions[self.session_id]['controller_to_accessory_count'] = 0 + + hkdf_inst = hkdf.Hkdf('Control-Salt'.encode(), shared_secret, hash=hashlib.sha512) + accessory_to_controller_key = hkdf_inst.expand('Control-Read-Encryption-Key'.encode(), 32) + self.server.sessions[self.session_id]['accessory_to_controller_key'] = accessory_to_controller_key + self.server.sessions[self.session_id]['accessory_to_controller_count'] = 0 + + d_res[TLV.kTLVType_State] = TLV.M4 + + self._send_response_tlv(d_res) + if self.debug_pair_verify: + self.log_message('after step #4\n%s', TLV.to_string(d_res)) + return + + self.send_error(HttpStatusCodes.METHOD_NOT_ALLOWED) + + def _post_pairings(self): + d_req = TLV.decode_bytes(self.body) + self.log_message('POST /pairings request body:\n%s', TLV.to_string(d_req)) + + session = self.server.sessions[self.session_id] + server_data = self.server.data + + d_res = {} + + if d_req[TLV.kTLVType_State] == TLV.M1 and d_req[TLV.kTLVType_Method] == TLV.AddPairing: + self.log_message('Step #2 /pairings add pairing') + # see page 51 + self.send_error(HttpStatusCodes.METHOD_NOT_ALLOWED) + return + + if d_req[TLV.kTLVType_State] == TLV.M1 and d_req[TLV.kTLVType_Method] == TLV.RemovePairing: + # step #2 Accessory -> iOS Device remove pairing response + self.log_message('Step #2 /pairings remove pairings') + + # 1) + + # 2) verify set admin bit + ios_device_pairing_id = session['ios_device_pairing_id'] + if not server_data.is_peer_admin(ios_device_pairing_id): + self.send_error_reply(TLV.M2, TLV.kTLVError_Authentication) + print('error in step #2: admin bit') + return + + # 3) remove pairing and republish device + server_data.remove_peer(d_req[TLV.kTLVType_Identifier]) + self.server.publish_device() + + d_res[TLV.kTLVType_State] = TLV.M2 + self._send_response_tlv(d_res) + self.log_message('after step #2\n%s', TLV.to_string(d_res)) + return + + if d_req[TLV.kTLVType_State] == TLV.M1 and d_req[TLV.kTLVType_Method] == TLV.ListPairings: + # step #2 Accessory -> iOS Device list pairing response + self.log_message('Step #2 /pairings list pairings') + + # 1) Validate against session + + # 2) verify set admin bit + ios_device_pairing_id = session['ios_device_pairing_id'] + if not server_data.is_peer_admin(ios_device_pairing_id): + self.send_error_reply(TLV.M2, TLV.kTLVError_Authentication) + print('error in step #2: admin bit') + return + + # 3) construct response TLV + tmp = [] + for pairing_id in server_data.peers: + tmp.append({ + TLV.kTLVType_Identifier: pairing_id.encode(), + TLV.kTLVType_PublicKey: server_data.get_peer_key(pairing_id.encode()), + TLV.kTLVType_Permissions: bytes(1) if server_data.is_peer_admin(pairing_id.encode()) else bytes(0) + }) + tmp[0][TLV.kTLVType_State] = TLV.M2 + + tmp2 = bytearray() + for tlv in tmp: + tmp2 += (TLV.encode_dict(tlv)) + tmp2 += (TLV.encode_dict({TLV.kTLVType_Separator: bytes()})) + + result_bytes = bytes(tmp2) + + # 4) send response + self.send_response(HttpStatusCodes.OK) + # Send headers + self.send_header('Content-Length', len(result_bytes)) + self.send_header('Content-Type', 'application/pairing+tlv8') + self.send_header('Connection', 'keep-alive') + self.end_headers() + + self.wfile.write(result_bytes) + return + + self.send_error(HttpStatusCodes.METHOD_NOT_ALLOWED) + + def send_error_reply(self, state, error): + """ + Send an error reply encoded as TLV. + :param state: The state as in TLV.M1, TLV.M2, ... + :param error: The error code as in TLV.kTLVError_* + :return: None + """ + d_res = dict() + d_res[TLV.kTLVType_State] = state + d_res[TLV.kTLVType_Error] = error + result_bytes = TLV.encode_dict(d_res) + + self.send_response(HttpStatusCodes.METHOD_NOT_ALLOWED) + # Send headers + self.send_header('Content-Length', len(result_bytes)) + self.send_header('Content-Type', 'application/pairing+tlv8') + self.end_headers() + + self.wfile.write(result_bytes) + + def _post_pair_setup(self): + d_req = TLV.decode_bytes(self.body) + self.log_message('POST /pair-setup request body:\n%s', TLV.to_string(d_req)) + + d_res = {} + + if d_req[TLV.kTLVType_State] == TLV.M1: + # step #2 Accessory -> iOS Device SRP Start Response + self.log_message('Step #2 /pair-setup') + + # 1) Check if paired + if self.server.data.is_paired: + self.send_error_reply(TLV.M2, TLV.kTLVError_Unavailable) + return + + # 2) Check if over 100 attempts + if self.server.data.unsuccessful_tries > 100: + self.log_error('to many failed attempts') + self.send_error_reply(TLV.M2, TLV.kTLVError_MaxTries) + return + + # 3) Check if already in pairing + if False: + self.send_error_reply(TLV.M2, TLV.kTLVError_Busy) + return + + # 4) 5) 7) Create in SRP Session, set username and password + server = SrpServer('Pair-Setup', self.server.data.setup_code) + + # 6) create salt + salt = server.get_salt() + + # 8) show setup code to user + sc = self.server.data.setup_code + sc_str = 'Setup Code\n┌─' + '─' * len(sc) + '─┐\n│ ' + sc + ' │\n└─' + '─' * len(sc) + '─┘' + self.log_message(sc_str) + + # 9) create public key + public_key = server.get_public_key() + + # 10) create response tlv and send response + d_res[TLV.kTLVType_State] = TLV.M2 + d_res[TLV.kTLVType_PublicKey] = SrpServer.to_byte_array(public_key) + d_res[TLV.kTLVType_Salt] = SrpServer.to_byte_array(salt) + self._send_response_tlv(d_res) + + # store session + self.server.sessions[self.session_id]['srp'] = server + self.log_message('after step #2:\n%s', TLV.to_string(d_res)) + return + + if d_req[TLV.kTLVType_State] == TLV.M3: + # step #4 Accessory -> iOS Device SRP Verify Response + self.log_message('Step #4 /pair-setup') + + # 1) use ios pub key to compute shared secret key + ios_pub_key = bytes_to_mpz(d_req[TLV.kTLVType_PublicKey]) + server = self.server.sessions[self.session_id]['srp'] + server.set_client_public_key(ios_pub_key) + + hkdf_inst = hkdf.Hkdf('Pair-Setup-Encrypt-Salt'.encode(), SrpServer.to_byte_array(server.get_session_key()), + hash=hashlib.sha512) + session_key = hkdf_inst.expand('Pair-Setup-Encrypt-Info'.encode(), 32) + self.server.sessions[self.session_id]['session_key'] = session_key + + # 2) verify ios proof + ios_proof = bytes_to_mpz(d_req[TLV.kTLVType_Proof]) + if not server.verify_clients_proof(ios_proof): + d_res[TLV.kTLVType_State] = TLV.M4 + d_res[TLV.kTLVType_Error] = TLV.kTLVError_Authentication + + self._send_response_tlv(d_res) + print('error in step #4', d_res, self.server.sessions) + return + else: + self.log_message('ios proof was verified') + + # 3) generate accessory proof + accessory_proof = server.get_proof(ios_proof) + + # 4) create response tlv + d_res[TLV.kTLVType_State] = TLV.M4 + d_res[TLV.kTLVType_Proof] = SrpServer.to_byte_array(accessory_proof) + + # 5) send response tlv + self._send_response_tlv(d_res) + + self.log_message('after step #4:\n%s', TLV.to_string(d_res)) + return + + if d_req[TLV.kTLVType_State] == TLV.M5: + # step #6 Accessory -> iOS Device Exchange Response + self.log_message('Step #6 /pair-setup') + + # 1) Verify the iOS device's authTag + # done by chacha20_aead_decrypt + + # 2) decrypt and test + encrypted_data = d_req[TLV.kTLVType_EncryptedData] + decrypted_data = chacha20_aead_decrypt(bytes(), self.server.sessions[self.session_id]['session_key'], + 'PS-Msg05'.encode(), bytes([0, 0, 0, 0]), + encrypted_data) + if decrypted_data == False: + d_res[TLV.kTLVType_State] = TLV.M6 + d_res[TLV.kTLVType_Error] = TLV.kTLVError_Authentication + + self.send_error_reply(TLV.M6, TLV.kTLVError_Authentication) + print('error in step #6', d_res, self.server.sessions) + return + + d_req_2 = TLV.decode_bytearray(decrypted_data) + + # 3) Derive ios_device_x + shared_secret = self.server.sessions[self.session_id]['srp'].get_session_key() + hkdf_inst = hkdf.Hkdf('Pair-Setup-Controller-Sign-Salt'.encode(), SrpServer.to_byte_array(shared_secret), + hash=hashlib.sha512) + ios_device_x = hkdf_inst.expand('Pair-Setup-Controller-Sign-Info'.encode(), 32) + + # 4) construct ios_device_info + ios_device_pairing_id = d_req_2[TLV.kTLVType_Identifier] + ios_device_ltpk = d_req_2[TLV.kTLVType_PublicKey] + ios_device_info = ios_device_x + ios_device_pairing_id + ios_device_ltpk + + # 5) verify signature + ios_device_sig = d_req_2[TLV.kTLVType_Signature] + + verify_key = py25519.Key25519(pubkey=bytes(), verifyingkey=bytes(ios_device_ltpk)) + if not verify_key.verify(bytes(ios_device_sig), bytes(ios_device_info)): + self.send_error_reply(TLV.M6, TLV.kTLVError_Authentication) + print('error in step #6', d_res, self.server.sessions) + return + + # 6) save ios_device_pairing_id and ios_device_ltpk + self.server.data.add_peer(ios_device_pairing_id, ios_device_ltpk) + + # Response Generation + # 1) generate accessoryLTPK if not existing + if self.server.data.accessory_ltsk is None or self.server.data.accessory_ltpk is None: + accessory_ltsk = py25519.Key25519() + accessory_ltpk = accessory_ltsk.verifyingkey + self.server.data.set_accessory_keys(accessory_ltpk, accessory_ltsk.secretkey) + else: + accessory_ltsk = py25519.Key25519(self.server.data.accessory_ltsk) + accessory_ltpk = accessory_ltsk.verifyingkey + + # 2) derive AccessoryX + hkdf_inst = hkdf.Hkdf('Pair-Setup-Accessory-Sign-Salt'.encode(), SrpServer.to_byte_array(shared_secret), + hash=hashlib.sha512) + accessory_x = hkdf_inst.expand('Pair-Setup-Accessory-Sign-Info'.encode(), 32) + + # 3) + accessory_info = accessory_x + self.server.data.accessory_pairing_id_bytes + accessory_ltpk + + # 4) generate signature + accessory_signature = accessory_ltsk.sign(accessory_info) + + # 5) construct sub_tlv + sub_tlv = { + TLV.kTLVType_Identifier: self.server.data.accessory_pairing_id_bytes, + TLV.kTLVType_PublicKey: accessory_ltpk, + TLV.kTLVType_Signature: accessory_signature + } + sub_tlv_b = TLV.encode_dict(sub_tlv) + + # 6) encrypt sub_tlv + encrypted_data_with_auth_tag = chacha20_aead_encrypt(bytes(), + self.server.sessions[self.session_id]['session_key'], + 'PS-Msg06'.encode(), + bytes([0, 0, 0, 0]), + sub_tlv_b) + tmp = bytearray(encrypted_data_with_auth_tag[0]) + tmp += encrypted_data_with_auth_tag[1] + + # 7) send response + self.server.publish_device() + d_res = dict() + d_res[TLV.kTLVType_State] = TLV.M6 + d_res[TLV.kTLVType_EncryptedData] = tmp + + self._send_response_tlv(d_res) + self.log_message('after step #6:\n%s', TLV.to_string(d_res)) + return + + self.send_error(HttpStatusCodes.METHOD_NOT_ALLOWED) + + def _send_response_tlv(self, d_res, close=False, status=HttpStatusCodes.OK): + result_bytes = TLV.encode_dict(d_res) + + self.send_response(status) + # Send headers + self.send_header('Content-Length', len(result_bytes)) + self.send_header('Content-Type', 'application/pairing+tlv8') + self.send_header('Connection', 'keep-alive') + self.end_headers() + + self.wfile.write(result_bytes) + + class Wrapper: + """ + Wraps a bytes or bytearray data into a file like object. + """ + + def __init__(self, data): + self.data = data + + def makefile(self, arg): + return io.BytesIO(self.data) + + def do_GET(self): + """ + Can use + * command + * headers + * path + * ... + :return: + """ + absolute_path = self.path.split('?')[0] + if absolute_path in self.PATHMAPPING: + if 'GET' in self.PATHMAPPING[absolute_path]: + # self.log_message('-' * 80 + '\ndo_GET / path: %s', self.path) + self.PATHMAPPING[absolute_path]['GET']() + return + self.log_error('send error because of unmapped path: %s', self.path) + self.send_error(HttpStatusCodes.NOT_FOUND) + + def do_POST(self): + # read the body identified by its length + content_length = int(self.headers['Content-Length']) + self.body = self.rfile.read(content_length) + if self.path in self.PATHMAPPING: + if 'POST' in self.PATHMAPPING[self.path]: + # self.log_message('-' * 80 + '\ndo_POST / path: %s', self.path) + self.PATHMAPPING[self.path]['POST']() + return + self.log_error('send error because of unmapped path: %s', self.path) + self.send_error(HttpStatusCodes.NOT_FOUND) + + def do_PUT(self): + # read the body identified by its length + content_length = int(self.headers['Content-Length']) + self.body = self.rfile.read(content_length) + if self.path in self.PATHMAPPING: + if 'PUT' in self.PATHMAPPING[self.path]: + # self.log_message('-' * 80 + '\ndo_PUT / path: %s', self.path) + self.PATHMAPPING[self.path]['PUT']() + return + self.log_error('send error because of unmapped path: %s', self.path) + self.send_error(HttpStatusCodes.NOT_FOUND) diff --git a/homekit/secure_http.py b/homekit/secure_http.py index a49dbdf2..1452e143 100644 --- a/homekit/secure_http.py +++ b/homekit/secure_http.py @@ -51,7 +51,6 @@ def put(self, target, body): 'Content-Type: application/hap+json\n' + \ 'Content-Length: {len}\n'.format(len=len(body)) data = 'PUT {tgt} HTTP/1.1\n{hdr}\n{body}'.format(tgt=target, hdr=headers, body=body) - print(data) return self._handle_request(data) def post(self, target, body): @@ -88,31 +87,29 @@ def _handle_response(self): # followed by 16 byte authTag blocks = [] tmp = bytearray() + exp_len = 512 while True: - data = self.sock.recv(256) - # print('len(read)', len(data)) - if not data: - break + data = self.sock.recv(exp_len) tmp += data - # print(tmp[0:2]) length = int.from_bytes(tmp[0:2], 'little') if length + 18 > len(tmp): - # print('continue because: ', length + 18, '>', len(tmp)) - # if the the amount of data in tmp is not length + 2 bytes for length + 16 bytes for the tag, the block # is not complete yet continue tmp = tmp[2:] - # print('length', length, 'of buffer length', len(tmp)) + block = tmp[0:length] - # print('len(block)', len(block)) tmp = tmp[length:] + tag = tmp[0:16] - # print('len(tag)', len(tag)) tmp = tmp[16:] - # print('len(tmp)', len(tmp)) + blocks.append((length, block, tag)) - # print('-' * 80) + + # check how long next block will be + if int.from_bytes(tmp[0:2], 'little') < 1024: + exp_len = int.from_bytes(tmp[0:2], 'little') - len(tmp) + 18 + if length < 1024: break diff --git a/homekit/server.py b/homekit/server.py new file mode 100644 index 00000000..1f1710c2 --- /dev/null +++ b/homekit/server.py @@ -0,0 +1,47 @@ +from http.server import HTTPServer +from socketserver import ThreadingMixIn +from zeroconf import Zeroconf, ServiceInfo +import socket + +from homekit.serverdata import HomeKitServerData +from homekit.request_handler import HomeKitRequestHandler +from homekit.model import Accessories, Categories + + +class HomeKitServer(ThreadingMixIn, HTTPServer): + def __init__(self, data_file): + self.data = HomeKitServerData(data_file) + self.data.increase_configuration_number() + self.sessions = {} + self.zeroconf = Zeroconf() + self.mdns_type = '_hap._tcp.local.' + self.mdns_name = self.data.name + '._hap._tcp.local.' + + self.accessories = Accessories() + + # accessory = homekit.model.Accessory(1, 'me') + # accessory.services.append(homekit.model.LightBulbService(2, 'light')) + # self.accessories.add_accessory(accessory) + HTTPServer.__init__(self, (self.data.ip, self.data.port), HomeKitRequestHandler) + + def publish_device(self): + desc = {'md': 'My Lightbulb', # model name of accessory + 'ci': Categories['Lightbulb'], # category identifier (page 254, 2 means bridge) + 'pv': '1.0', # protocol version + 'c#': str(self.data.configuration_number), + # configuration (consecutive number, 1 or greater, must be changed on every configuration change) + 'id': self.data.accessory_pairing_id_bytes, # id MUST look like Mac Address + 'ff': '0', # feature flags + 's#': '1', # must be 1 + 'sf': '1' # status flag, lowest bit encodes pairing status, 1 means unpaired + } + if self.data.is_paired: + desc['sf'] = '0' + + info = ServiceInfo(self.mdns_type, self.mdns_name, socket.inet_aton(self.data.ip), self.data.port, 0, 0, desc, + 'ash-2.local.') + self.zeroconf.unregister_all_services() + self.zeroconf.register_service(info) + + def unpublish_device(self): + self.zeroconf.unregister_all_services() diff --git a/homekit/serverdata.py b/homekit/serverdata.py new file mode 100644 index 00000000..f6a92831 --- /dev/null +++ b/homekit/serverdata.py @@ -0,0 +1,125 @@ +import json +import binascii + + +class HomeKitServerData: + """ + This class is used to take care of the servers persistence to be able to managage restarts, + """ + + def __init__(self, data_file): + self.data_file = data_file + with open(data_file, 'r') as input_file: + self.data = json.load(input_file) + + def _save_data(self): + with open(self.data_file, 'w') as output_file: + # print(json.dumps(self.data, indent=2, sort_keys=True)) + json.dump(self.data, output_file, indent=2, sort_keys=True) + + @property + def ip(self) -> str: + return self.data['host_ip'] + + @property + def port(self) -> int: + return self.data['host_port'] + + @property + def setup_code(self) -> str: + return self.data['accessory_pin'] + + @property + def accessory_pairing_id_bytes(self) -> bytes: + return self.data['accessory_pairing_id'].encode() + + @property + def unsuccessful_tries(self) -> int: + return self.data['unsuccessful_tries'] + + def register_unsuccessful_try(self): + self.data['unsuccessful_tries'] += 1 + self._save_data() + + @property + def is_paired(self) -> bool: + return len(self.data['peers']) > 0 + + @property + def name(self) -> str: + return self.data['name'] + + def remove_peer(self, pairing_id: bytes): + del self.data['peers'][pairing_id.decode()] + self._save_data() + + def add_peer(self, pairing_id: bytes, ltpk: bytes): + admin = (len(self.data['peers']) == 0) + self.data['peers'][pairing_id.decode()] = {'key': binascii.hexlify(ltpk).decode(), 'admin': admin} + self._save_data() + + def get_peer_key(self, pairing_id: bytes) -> bytes: + if pairing_id.decode() in self.data['peers']: + return bytes.fromhex(self.data['peers'][pairing_id.decode()]['key']) + else: + return None + + def is_peer_admin(self, pairing_id: bytes) -> bool: + return self.data['peers'][pairing_id.decode()]['admin'] + + @property + def peers(self): + return self.data['peers'].keys() + + @property + def accessory_ltsk(self) -> bytes: + if 'accessory_ltsk' in self.data: + return bytes.fromhex(self.data['accessory_ltsk']) + else: + return None + + @property + def accessory_ltpk(self) -> bytes: + if 'accessory_ltpk' in self.data: + return bytes.fromhex(self.data['accessory_ltpk']) + else: + return None + + def set_accessory_keys(self, accessory_ltpk: bytes, accessory_ltsk: bytes): + self.data['accessory_ltpk'] = binascii.hexlify(accessory_ltpk).decode() + self.data['accessory_ltsk'] = binascii.hexlify(accessory_ltsk).decode() + self._save_data() + + @property + def configuration_number(self) -> int: + return self.data['c#'] + + def increase_configuration_number(self): + self.data['c#'] += 1 + self._save_data() + + +if __name__ == '__main__': + import tempfile + + fp = tempfile.NamedTemporaryFile(mode='w') + data = { + 'host_ip': '12.34.56.78', + 'port': 4711, + 'accessory_pin': '123-45-678', + 'accessory_pairing_id': '12:34:56:78:90:AB', + 'name': 'test007', + 'unsuccessful_tries': 0 + } + json.dump(data, fp) + fp.flush() + + print(fp.name) + + hksd = HomeKitServerData(fp.name) + print(hksd.accessory_pairing_id_bytes) + pk = bytes([0x12, 0x34]) + sk = bytes([0x56, 0x78]) + hksd.set_accessory_keys(pk, sk) + assert hksd.accessory_ltpk == pk + assert hksd.accessory_ltsk == sk diff --git a/homekit/services.py b/homekit/services.py deleted file mode 100644 index 483ef19a..00000000 --- a/homekit/services.py +++ /dev/null @@ -1,69 +0,0 @@ -class _ServicesTypes(object): - """ - This data is taken from Table 12-3 Accessory Categories on page 254. Values above 19 are reserved. - """ - - def __init__(self): - self.baseUUID = '-0000-1000-8000-0026BB765291' - self._services = { - '3E': 'public.hap.service.accessory-information', - '40': 'public.hap.service.fan', - '41': 'public.hap.service.garage-door-opener', - '43': 'public.hap.service.lightbulb', - '44': 'public.hap.service.lock-management', - '45': 'public.hap.service.lock-mechanism', - '47': 'public.hap.service.outlet', - '49': 'public.hap.service.switch', - '4A': 'public.hap.service.thermostat', - '7E': 'public.hap.service.security-system', - '7F': 'public.hap.service.sensor.carbon-monoxide', - '80': 'public.hap.service.sensor.contact', - '81': 'public.hap.service.door', - '82': 'public.hap.service.sensor.humidity', - '83': 'public.hap.service.sensor.leak', - '84': 'public.hap.service.sensor.light', - '85': 'public.hap.service.sensor.motion', - '86': 'public.hap.service.sensor.occupancy', - '87': 'public.hap.service.sensor.smoke', - '89': 'public.hap.service.stateless-programmable-switch', - '8A': 'public.hap.service.sensor.temperature', - '8B': 'public.hap.service.window', - '8C': 'public.hap.service.window-covering', - '8D': 'public.hap.service.sensor.air-quality', - '96': 'public.hap.service.battery', - '97': 'public.hap.service.sensor.carbon-dioxide', - 'B7': 'public.hap.service.fanv2', - 'B9': 'public.hap.service.vertical-slat', - 'BA': 'public.hap.service.filter-maintenance', - 'BB': 'public.hap.service.air-purifier', - 'CC': 'public.hap.service.service-label', - '110': 'public.hap.service.camera-rtp-stream-management', - '112': 'public.hap.service.microphone', - '113': 'public.hap.service.speaker', - '121': 'public.hap.service.doorbell', - } - - self._services_rev = {self._services[k]: k for k in self._services.keys()} - - def __getitem__(self, item): - if item in self._services: - return self._services[item] - - if item in self._services_rev: - return self._services_rev[item] - - # raise KeyError('Item {item} not found'.format_map(item=item)) - return 'Unknown Service: {i}'.format(i=item) - - def get_short(self, item): - orig_item = item - if item.endswith(self.baseUUID): - item = item.split('-', 1)[0] - item = item.lstrip('0') - - if item in self._services: - return self._services[item].split('.')[-1] - return 'Unknown Service: {i}'.format(i=orig_item) - - -ServicesTypes = _ServicesTypes() diff --git a/homekit/statuscodes.py b/homekit/statuscodes.py index 7dbad397..9a269ec3 100644 --- a/homekit/statuscodes.py +++ b/homekit/statuscodes.py @@ -1,7 +1,8 @@ -class _StatusCodes(object): +class _HapStatusCodes(object): """ This data is taken from Table 5-12 HAP Satus Codes on page 80. """ + INSUFFICIENT_PRIVILEGES = -70401 def __init__(self): self._codes = { @@ -28,4 +29,38 @@ def __getitem__(self, item): raise KeyError('Item {item} not found'.format_map(item=item)) -StatusCodes = _StatusCodes() +class _HttpStatuCodes: + """ + See Table 4-2 Chapter 4.15 Page 59 + """ + OK = 200 + NO_CONTENT = 204 + BAD_REQUEST = 400 + FORBIDDEN = 403 + NOT_FOUND = 404 + METHOD_NOT_ALLOWED = 405 + TOO_MANY_REQUESTS = 429 + CONNECTION_AUTHORIZATION_REQUIRED = 470 + INTERNAL_SERVER_ERROR = 500 + + def __init__(self): + self._codes = { + 200: 'OK', + 204: 'No Content', + 400: 'Bad Request', + 405: 'Method Not Allowed', + 429: 'Too Many Requests', + 470: 'Connection Authorization Required', + 500: 'Internal Server Error' + } + self._categories_rev = {self._codes[k]: k for k in self._codes.keys()} + + def __getitem__(self, item): + if item in self._codes: + return self._codes[item] + + raise KeyError('Item {item} not found'.format_map(item=item)) + + +HapStatusCodes = _HapStatusCodes() +HttpStatusCodes = _HttpStatuCodes() diff --git a/homekit/zeroconf.py b/homekit/zeroconf.py index 5d69f5fa..06e876aa 100644 --- a/homekit/zeroconf.py +++ b/homekit/zeroconf.py @@ -1,9 +1,9 @@ -from zeroconf import ServiceBrowser, Zeroconf -from time import sleep from socket import inet_ntoa +from time import sleep +from zeroconf import ServiceBrowser, Zeroconf from homekit.feature_flags import FeatureFlags -from homekit.categories import Categories +from homekit.model.categories import Categories class CollectingListener(object): diff --git a/requirements.txt b/requirements.txt index f103fbf1..43f51a1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ gmpy2 py25519 hkdf ed25519 +netifaces diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..224a7795 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.md \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..d4d4834b --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +from distutils.core import setup + +setup( + name='homekit', + packages=['homekit', 'homekit.model'], # this must be the same as the name above + version='0.1.6', + description='Python code to interface HomeKit Accessories and Controllers', + author='Joachim Lusiardi', + author_email='pypi@lusiardi.de', + url='https://github.com/jlusiardi/homekit_python', # use the URL to the github repo + download_url='https://github.com/jlusiardi/homekit_python/archive/0.1.tar.gz', # I'll explain this in a second + keywords=['HomeKit'], # arbitrary keywords + classifiers=[], + install_requires=[ + 'zeroconf', + 'gmpy2', + 'py25519', + 'hkdf', + 'ed25519', + 'netifaces', + ], +)