├── tests ├── __init__.py ├── test_util.py ├── test_color.py └── test_protocol.py ├── lifx ├── __init__.py ├── util.py ├── color.py ├── group.py ├── network.py ├── client.py ├── device.py └── protocol.py ├── doc └── source │ ├── modules.rst │ ├── lifx.rst │ ├── index.rst │ └── conf.py ├── README.md ├── setup.cfg ├── Makefile ├── examples ├── discovery.py ├── console.py ├── device_info.py └── dissector.py ├── setup.py ├── .gitignore └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lifx/__init__.py: -------------------------------------------------------------------------------- 1 | from client import Client 2 | 3 | -------------------------------------------------------------------------------- /doc/source/modules.rst: -------------------------------------------------------------------------------- 1 | lifx 2 | ==== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | lifx 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-lifx-sdk 2 | An SDK for local LAN control of bulbs, using Python 3 | 4 | The script has only been designed to work on Python 2. 5 | 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = doc/source 3 | build-dir = doc/build 4 | all_files = 1 5 | 6 | [upload_sphinx] 7 | upload-dir = doc/build/html 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | _all: doc sdist 2 | 3 | doc: doc/source/* 4 | sphinx-build -b html doc/source doc/build/html 5 | 6 | sdist: lifx/*.py 7 | ./setup.py sdist 8 | 9 | upload: 10 | ./setup.py sdist upload 11 | 12 | doc-upload: doc 13 | ./setup.py upload_sphinx 14 | 15 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import time 3 | 4 | from lifx.util import RepeatTimer 5 | 6 | class UtilTests(unittest.TestCase): 7 | def test_timer(self): 8 | def trigger(): 9 | trigger.counter += 1 10 | 11 | trigger.counter = 0 12 | 13 | timer = RepeatTimer(0.005, trigger) 14 | timer.start() 15 | 16 | time.sleep(0.04) 17 | timer.cancel() 18 | 19 | self.assertGreaterEqual(trigger.counter, 6) 20 | 21 | -------------------------------------------------------------------------------- /examples/discovery.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import signal 3 | import sys 4 | import time 5 | import lifx 6 | 7 | # Install a signal handler 8 | def signal_handler(signal, frame): 9 | print 'CTRL-C received. Exiting.' 10 | sys.exit(0) 11 | 12 | signal.signal(signal.SIGINT, signal_handler) 13 | 14 | # Start the client 15 | lights = lifx.Client() 16 | 17 | while True: 18 | # Give some time for discovery 19 | time.sleep(1) 20 | 21 | # Print results 22 | print '---- DEVICES ----' 23 | for i in lights.get_devices(): 24 | print i.label 25 | 26 | -------------------------------------------------------------------------------- /examples/console.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import code 4 | import lifx 5 | 6 | # Try to get readline if available 7 | try: 8 | import readline 9 | except ImportError: 10 | try: 11 | import pyreadline as readline 12 | except ImportError: 13 | pass 14 | 15 | broadcast_addr = os.environ.get('LIFX_BROADCAST', '255.255.255.255') 16 | 17 | # Start the client 18 | lights = lifx.Client(broadcast=broadcast_addr) 19 | 20 | # Start interactive console 21 | shell = code.InteractiveConsole({'lights': lights}) 22 | shell.interact(banner='Use the "lights" variable to use the SDK') 23 | 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | # Project Details 7 | name='lifx-sdk', 8 | version='0.8', 9 | packages=['lifx'], 10 | 11 | # Dependencies 12 | install_requires=[ 13 | 'bitstruct==1.0.0', 14 | ], 15 | 16 | # Tests 17 | test_suite="nose.collector", 18 | tests_require = [ 19 | 'nose', 20 | ], 21 | 22 | # Metadata for PyPI 23 | description='An SDK for local LAN control of bulbs, using Python', 24 | author='Daniel Hall', 25 | author_email='python-lifx-sdk@danielhall.me', 26 | url='http://www.danielhall.me/', 27 | ) 28 | 29 | -------------------------------------------------------------------------------- /lifx/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | @author: Brian Curtin 3 | http://code.activestate.com/lists/python-ideas/8982/ 4 | """ 5 | import threading 6 | 7 | class RepeatTimer(threading.Thread): 8 | def __init__(self, interval, callable, *args, **kwargs): 9 | threading.Thread.__init__(self) 10 | self.interval = interval 11 | self.callable = callable 12 | self.args = args 13 | self.kwargs = kwargs 14 | self.event = threading.Event() 15 | self.event.set() 16 | 17 | def run(self): 18 | while self.event.is_set(): 19 | t = threading.Timer(self.interval, self.callable, 20 | self.args, self.kwargs) 21 | t.start() 22 | t.join() 23 | 24 | def cancel(self): 25 | self.event.clear() 26 | -------------------------------------------------------------------------------- /examples/device_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import signal 3 | import sys 4 | import time 5 | import lifx 6 | 7 | # Install a signal handler 8 | def signal_handler(signal, frame): 9 | print 'CTRL-C received. Exiting.' 10 | sys.exit(0) 11 | 12 | signal.signal(signal.SIGINT, signal_handler) 13 | 14 | # Start the client 15 | lights = lifx.Client() 16 | 17 | # Give some time for discovery 18 | time.sleep(1) 19 | 20 | # Print results 21 | for i in lights.get_devices(): 22 | print '--- Device: "%s" ---' % i.label 23 | print 'Power: %r' % i.power 24 | color = i.color 25 | print 'Color:' 26 | print ' Hue: %d' % color.hue 27 | print ' Saturation: %d' % color.saturation 28 | print ' Brightness: %d' % color.brightness 29 | print ' Kelvin: %d' % color.kelvin 30 | 31 | -------------------------------------------------------------------------------- /examples/dissector.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import signal 3 | import sys 4 | 5 | from binascii import unhexlify 6 | import lifx.protocol 7 | 8 | # Install a signal handler 9 | def signal_handler(signal, frame): 10 | print 'CTRL-C received. Exiting.' 11 | sys.exit(0) 12 | 13 | signal.signal(signal.SIGINT, signal_handler) 14 | 15 | print '\n' 16 | print '---### LIFX Protocol Dissector ###---' 17 | print 'Get packets from Wireshark and dissect them here.' 18 | print 'Choose the "Data" section, right click it, then' 19 | print 'choose Copy -> Bytes -> Hex Stream, then paste the' 20 | print 'result here.\n' 21 | print 'CTRL-C or CTRL-D to exit.' 22 | print '\n' 23 | 24 | while True: 25 | try: 26 | line = raw_input('Packet: ') 27 | except EOFError: 28 | print 'EOF received. Exiting.' 29 | break 30 | print repr(lifx.protocol.parse_packet(unhexlify(line))) + '\n' 31 | 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Daniel Hall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /tests/test_color.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from lifx.color import HSBK, color_from_message, message_from_color, modify_color 4 | 5 | COLOR_MESSAGE_TEST = [ 6 | (HSBK(0, 0, 0, 0), HSBK(0, 0, 0, 0)), 7 | (HSBK(360, 1, 1, 9000), HSBK(pow(2, 16), pow(2, 16), pow(2, 16), 9000)), 8 | (HSBK(0, 0, 0.5, 3000), HSBK(0, 0, 32767, 3000)), 9 | ] 10 | 11 | class ColorTests(unittest.TestCase): 12 | def test_color_from_message(self): 13 | for color, message in COLOR_MESSAGE_TEST: 14 | newcolor = color_from_message(message) 15 | for i in range(0, len(HSBK._fields)): 16 | self.assertAlmostEqual(color[i], newcolor[i], places=1) 17 | 18 | def test_message_from_color(self): 19 | for color, message in COLOR_MESSAGE_TEST: 20 | newmessage = message_from_color(color) 21 | for i in range(0, len(HSBK._fields)): 22 | self.assertAlmostEqual(message[i], newmessage[i], delta=1) 23 | 24 | def test_modify_color(self): 25 | beforecolor = HSBK(0, 0, 0, 0) 26 | aftercolor = HSBK(120, 0, 0, 0) 27 | changedcolor = modify_color(beforecolor, hue=120) 28 | self.assertEqual(changedcolor, aftercolor) 29 | 30 | -------------------------------------------------------------------------------- /doc/source/lifx.rst: -------------------------------------------------------------------------------- 1 | lifx package 2 | ============ 3 | 4 | Submodules 5 | ---------- 6 | 7 | lifx.client module 8 | ------------------ 9 | 10 | .. automodule:: lifx.client 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | lifx.color module 16 | ----------------- 17 | 18 | .. automodule:: lifx.color 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | lifx.device module 24 | ------------------ 25 | 26 | .. automodule:: lifx.device 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | lifx.group module 32 | ------------------ 33 | 34 | .. automodule:: lifx.group 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | lifx.network module 40 | ------------------- 41 | 42 | .. automodule:: lifx.network 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | lifx.protocol module 48 | -------------------- 49 | 50 | .. automodule:: lifx.protocol 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | lifx.util module 56 | ---------------- 57 | 58 | .. automodule:: lifx.util 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | 64 | Module contents 65 | --------------- 66 | 67 | .. automodule:: lifx 68 | :members: 69 | :undoc-members: 70 | :show-inheritance: 71 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. lifx-sdk documentation master file, created by 2 | sphinx-quickstart on Tue Aug 4 22:06:59 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to lifx-sdk's documentation! 7 | ==================================== 8 | 9 | Contents 10 | -------- 11 | 12 | .. toctree:: 13 | :maxdepth: 1 14 | 15 | SDK Documentation 16 | 17 | Getting Started 18 | --------------- 19 | 20 | Controlling the lights with this SDK has been designed to be as easy as 21 | possible. The SDK handles discovering bulbs and assessing their reachability. 22 | 23 | The main entrypoint to the SDK is the :class:`lifx.client.Client` class. It is 24 | responsible for the discovery, reachability and querying the light bulbs. It 25 | returns :class:`lifx.device.Device` objects which represent individual lights. 26 | These two classes will make up the bulk of interactions with the SDK. 27 | 28 | A simple workflow for accessing a light is as follows: 29 | 30 | #. Create a :class:`lifx.client.Client` class. 31 | #. Wait for discovery to complete 32 | #. Select relevent bulbs. 33 | #. Perform an action on the bulbs. 34 | 35 | This is demonstrated in the following code: 36 | 37 | .. code-block:: python 38 | 39 | import lifx 40 | import time 41 | 42 | # Create the client and start discovery 43 | lights = lifx.Client() 44 | 45 | # Wait for discovery to complete 46 | time.sleep(1) 47 | 48 | # Turn all bulbs off 49 | for l in lights.get_devices(): 50 | print 'Turning off %s' % l.label 51 | l.power = False 52 | 53 | Indices and tables 54 | ================== 55 | 56 | * :ref:`genindex` 57 | * :ref:`modindex` 58 | * :ref:`search` 59 | 60 | -------------------------------------------------------------------------------- /lifx/color.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import protocol 3 | 4 | HUE_MAX = 360 5 | KELVIN_MIN = 2500 6 | KELVIN_MAX = 9000 7 | KELVIN_RANGE = KELVIN_MAX - KELVIN_MIN 8 | 9 | MID_KELVIN = KELVIN_MIN + (KELVIN_RANGE/2) 10 | 11 | HSBK = namedtuple('HSBK', ['hue', 'saturation', 'brightness', 'kelvin']) 12 | 13 | # Bright Colors 14 | RED = HSBK(0, 1, 1, MID_KELVIN) 15 | YELLOW = HSBK(60, 1, 1, MID_KELVIN) 16 | GREEN = HSBK(120, 1, 1, MID_KELVIN) 17 | AQUA = HSBK(180, 1, 1, MID_KELVIN) 18 | BLUE = HSBK(240, 1, 1, MID_KELVIN) 19 | PURPLE = HSBK(300, 1, 1, MID_KELVIN) 20 | WHITE = HSBK(0, 0, 1, MID_KELVIN) 21 | 22 | # Whites 23 | COOL_WHITE = HSBK(0, 0, 1, KELVIN_MAX) 24 | WARM_WHITE = HSBK(0, 0, 1, KELVIN_MIN) 25 | 26 | 27 | def color_from_message(state): 28 | """ 29 | Translates values from a packet into actual color values 30 | 31 | :param state: The state data from the light state message 32 | :returns: HSBK -- The actual color values 33 | """ 34 | hue = float(state.hue) / protocol.UINT16_MAX * HUE_MAX 35 | saturation = float(state.saturation) / protocol.UINT16_MAX 36 | brightness = float(state.brightness) / protocol.UINT16_MAX 37 | kelvin = int(state.kelvin) 38 | 39 | return HSBK(hue, saturation, brightness, kelvin) 40 | 41 | def message_from_color(hsbk): 42 | """ 43 | Translates values from a color to values suitable for the packet. 44 | 45 | :param hsbk: The data from the actual color 46 | :returns: HSBK -- Color values for a light state message 47 | """ 48 | msghue = int(float(hsbk.hue) / HUE_MAX * protocol.UINT16_MAX) 49 | msgsat = int(float(hsbk.saturation) * protocol.UINT16_MAX) 50 | msgbrt = int(float(hsbk.brightness) * protocol.UINT16_MAX) 51 | msgkvn = int(hsbk.kelvin) % (protocol.UINT16_MAX + 1) 52 | 53 | return HSBK(msghue, msgsat, msgbrt, msgkvn) 54 | 55 | def modify_color(hsbk, **kwargs): 56 | """ 57 | Helper function to make new colors from an existing color by modifying it. 58 | 59 | :param hsbk: The base color 60 | :param hue: The new Hue value (optional) 61 | :param saturation: The new Saturation value (optional) 62 | :param brightness: The new Brightness value (optional) 63 | :param kelvin: The new Kelvin value (optional) 64 | """ 65 | return hsbk._replace(**kwargs) 66 | 67 | -------------------------------------------------------------------------------- /lifx/group.py: -------------------------------------------------------------------------------- 1 | from device import DEFAULT_DURATION 2 | 3 | import protocol 4 | 5 | class Group(object): 6 | def __init__(self, group_id, client, member_func, label_func): 7 | self._client = client 8 | self._id = group_id 9 | self._membership_func = member_func 10 | self._label_func = label_func 11 | 12 | def __repr__(self): 13 | return "" % (repr(self.label), repr(self.members)) 14 | 15 | def __getitem__(self, key): 16 | return self.members[key] 17 | 18 | @property 19 | def members(self): 20 | """ 21 | A list of lights in this group. 22 | """ 23 | return self._membership_func(self._id) 24 | 25 | @property 26 | def id(self): 27 | """ 28 | The ID of this group 29 | """ 30 | return self._id 31 | 32 | @property 33 | def label(self): 34 | """ 35 | The Label on this group 36 | """ 37 | labels = map(self._label_func, self.members) 38 | labels.sort(key=lambda k:k.updated_at) 39 | return protocol.bytes_to_label(labels[0].label) 40 | 41 | def fade_power(self, power, duration=DEFAULT_DURATION): 42 | """ 43 | Change the power state of the entire group at once. 44 | 45 | :param power: The power state to transition every bulb in the group to. 46 | :param duration: The amount of time to perform the transition over. 47 | """ 48 | for l in self.members: 49 | l.fade_power(power, duration) 50 | 51 | def power_toggle(self, duration=DEFAULT_DURATION): 52 | """ 53 | Toggle the power of every member of the group individually. 54 | Essentially it inverts the power state across the group. 55 | 56 | :param duration: The amount of time to perform the transition over. 57 | """ 58 | for l in self.members: 59 | l.power_toggle(duration) 60 | 61 | def fade_color(self, newcolor, duration=DEFAULT_DURATION): 62 | """ 63 | Change the color of the entire group at once. 64 | 65 | :param power: The color to transition every bulb in the group to. 66 | :param duration: The amount of time to perform the transition over. 67 | """ 68 | for l in self.members: 69 | l.fade_color(newcolor, duration) 70 | 71 | -------------------------------------------------------------------------------- /lifx/network.py: -------------------------------------------------------------------------------- 1 | import protocol 2 | import socket 3 | import threading 4 | from binascii import hexlify 5 | from collections import namedtuple 6 | 7 | DEFAULT_LIFX_PORT = 56700 8 | 9 | PacketHandler = namedtuple('PacketHandler', ['handler', 'pktfilter']) 10 | default_filter = lambda x:True 11 | 12 | class NetworkTransport(object): 13 | """The network transport manages the network sockets and the networking threads""" 14 | def __init__(self, address='0.0.0.0', broadcast='255.255.255.255'): 15 | # Prepare a socket 16 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 17 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 18 | sock.bind((address, 0)) 19 | 20 | self._socket = sock 21 | 22 | self._listener = ListenerThread(sock, self._handle_packet) 23 | self._listener.start() 24 | 25 | self._packet_handlers = {} 26 | 27 | self._current_handler_id = 0 28 | 29 | self._broadcast = broadcast 30 | 31 | def _sendto(self, packet, address, port): 32 | return self._socket.sendto(packet, (address, port)) 33 | 34 | def send_packet(self, *args, **kwargs): 35 | # Make the packet 36 | packetargs = kwargs.copy() 37 | del packetargs['address'] 38 | del packetargs['port'] 39 | packet = protocol.make_packet(*args, **packetargs) 40 | 41 | # Send it 42 | address = kwargs['address'] 43 | port = kwargs['port'] 44 | return self._sendto(packet, address, port) 45 | 46 | def send_discovery(self, source, sequence): 47 | return self._sendto(protocol.discovery_packet(source, sequence), self._broadcast, DEFAULT_LIFX_PORT) 48 | 49 | def register_packet_handler(self, handler, pktfilter=default_filter): 50 | # Save handler 51 | self._packet_handlers[self._current_handler_id] = PacketHandler(handler, pktfilter) 52 | 53 | # move to next handler id 54 | self._current_handler_id += 1 55 | 56 | def _handle_packet(self, address, packet): 57 | for h in self._packet_handlers.values(): 58 | if h.pktfilter(packet): 59 | host, port = address 60 | h.handler(host, port, packet) 61 | 62 | class ListenerThread(threading.Thread): 63 | """The Listener Thread grabs incoming packets, parses them and forwards them to the right listeners""" 64 | def __init__(self, socket, handler): 65 | super(ListenerThread, self).__init__( 66 | name='ListenerThread' 67 | ) 68 | 69 | # Exit on script exit 70 | self.daemon = True 71 | 72 | # Store instance data 73 | self._socket = socket 74 | self._handler = handler 75 | 76 | def run(self): 77 | while True: 78 | data, addr = self._socket.recvfrom(1500) 79 | self._handler(addr, protocol.parse_packet(data)) 80 | 81 | -------------------------------------------------------------------------------- /tests/test_protocol.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from binascii import hexlify, unhexlify 3 | 4 | import lifx.protocol 5 | 6 | MAC_TEST_CASES = [ 7 | (4930653221840, 'd073d5017c04'), # Actual Device 8 | (0, '000000000000'), # Minimum 9 | (pow(2, 6*8) - 1, 'ffffffffffff'), # Maximum 10 | ] 11 | 12 | VERSION_TEST_CASES = [ 13 | (0x00020001, '2.1'), 14 | ] 15 | 16 | BYTES_TO_LABEL_CASES = [ 17 | (bytearray(b'Right \xe2\x86\x97\xef\xb8\x8f\x00'), u'Right \u2197\ufe0f'), 18 | (bytearray(b'\x00'), u''), 19 | (bytearray(b'Just Text\x00'), u'Just Text'), 20 | (bytearray(b'\x00AFTER_NULL'), u''), 21 | ] 22 | 23 | PACK_TEST_CASES = [ 24 | (lifx.protocol.frame_header, (0, 0, 0, 0, 0, 0,), '0000000000000000'), 25 | (lifx.protocol.frame_header, (49, 0, 1, 1, 1024, 4752,), '3100003490120000'), 26 | (lifx.protocol.protocol_header, (0, 0, 0), '000000000000000000000000'), 27 | (lifx.protocol.protocol_header, (0, 117, 0), '000000000000000075000000'), 28 | ] 29 | 30 | SIZE_TEST_CASES = [ 31 | (lifx.protocol.frame_header, 64), 32 | (lifx.protocol.frame_address, 128), 33 | (lifx.protocol.protocol_header, 96), 34 | (lifx.protocol.messages[lifx.protocol.TYPE_GETSERVICE], 0), 35 | (lifx.protocol.messages[lifx.protocol.TYPE_STATESERVICE], 40), 36 | ] 37 | 38 | EXAMPLE_PACKETS = [ 39 | ({ 40 | 'source': 45, 41 | 'target': 4930653221840, 42 | 'ack_required': False, 43 | 'res_required': True, 44 | 'sequence': 97, 45 | 'pkt_type': lifx.protocol.TYPE_GETPOWER, 46 | }, 47 | (), 48 | '240000142d000000d073d5017c0400000000000000000161000000000000000014000000', 49 | { 50 | 'tagged': False, 51 | }, 52 | ), 53 | ] 54 | 55 | class ProtocolTests(unittest.TestCase): 56 | def test_mac_string(self): 57 | for val, mac in MAC_TEST_CASES: 58 | self.assertEqual(lifx.protocol.mac_string(val), mac) 59 | 60 | def test_version_string(self): 61 | for vnum, vstr in VERSION_TEST_CASES: 62 | self.assertEqual(lifx.protocol.version_string(vnum), vstr) 63 | 64 | def test_bytes_to_label(self): 65 | for val, string in BYTES_TO_LABEL_CASES: 66 | self.assertEqual(lifx.protocol.bytes_to_label(val), string) 67 | 68 | def test_pack_section(self): 69 | for pack_type, args, packet in PACK_TEST_CASES: 70 | self.assertEqual(hexlify(lifx.protocol.pack_section(pack_type, *args)), packet) 71 | 72 | def test_unpack_section(self): 73 | for pack_type, args, packet in PACK_TEST_CASES: 74 | self.assertEqual(lifx.protocol.unpack_section(pack_type, unhexlify(packet)), args) 75 | 76 | def test_section_size(self): 77 | for section, size in SIZE_TEST_CASES: 78 | self.assertEqual(lifx.protocol.section_size(section), size) 79 | 80 | def test_make_packet(self): 81 | for kwargs, args, packet, vals in EXAMPLE_PACKETS: 82 | self.assertEqual(hexlify(lifx.protocol.make_packet(*args, **kwargs)), packet) 83 | 84 | def test_parse_packet(self): 85 | for kwargs, args, packet, vals in EXAMPLE_PACKETS: 86 | parsed = lifx.protocol.parse_packet(unhexlify(packet)) 87 | 88 | size = len(unhexlify(packet)) 89 | 90 | # Check data we sent 91 | self.assertEqual(parsed.frame_header.size, size) 92 | self.assertEqual(parsed.frame_header.protocol, 1024) 93 | self.assertEqual(parsed.frame_header.source, kwargs['source']) 94 | self.assertEqual(parsed.frame_address.target, kwargs['target']) 95 | self.assertEqual(parsed.frame_address.ack_required, kwargs['ack_required']) 96 | self.assertEqual(parsed.frame_address.res_required, kwargs['res_required']) 97 | self.assertEqual(parsed.frame_address.sequence, kwargs['sequence']) 98 | self.assertEqual(parsed.protocol_header.pkt_type, kwargs['pkt_type']) 99 | 100 | # Check other data 101 | self.assertEqual(parsed.frame_header.tagged, vals['tagged']) 102 | 103 | def test_parse_packet_with_incorrect_size(self): 104 | packet = '240000142d000000d073d5017c0400000000000000000161000000000000000014' 105 | self.assertIsNone(lifx.protocol.parse_packet(unhexlify(packet))) 106 | 107 | def test_parse_packet_with_unknown_type(self): 108 | packet = '2600005442524b52d073d501cf2c00004c49465856320000842e3128d92cf7136f000000890c' 109 | parsed = lifx.protocol.parse_packet(unhexlify(packet)) 110 | self.assertEqual(parsed.protocol_header.pkt_type, 111) 111 | self.assertEqual(parsed.payload, '\x89\x0c') 112 | 113 | def test_discovery_packet(self): 114 | self.assertEqual( 115 | hexlify(lifx.protocol.discovery_packet(23, 5)), 116 | '240000341700000000000000000000000000000000000105000000000000000002000000', 117 | ) 118 | 119 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # lifx-sdk documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Aug 4 22:06:59 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import shlex 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | #sys.path.insert(0, os.path.abspath('.')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx.ext.intersphinx', 35 | 'sphinx.ext.ifconfig', 36 | ] 37 | 38 | autoclass_content = 'both' 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix(es) of source filenames. 44 | # You can specify multiple suffix as a list of string: 45 | # source_suffix = ['.rst', '.md'] 46 | source_suffix = '.rst' 47 | 48 | # The encoding of source files. 49 | #source_encoding = 'utf-8-sig' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = u'lifx-sdk' 56 | copyright = u'2015, Daniel Hall' 57 | author = u'Daniel Hall' 58 | 59 | # The version info for the project you're documenting, acts as replacement for 60 | # |version| and |release|, also used in various other places throughout the 61 | # built documents. 62 | # 63 | # The short X.Y version. 64 | version = '0.7' 65 | # The full version, including alpha/beta/rc tags. 66 | release = '0.8' 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | # 71 | # This is also used if you do content translation via gettext catalogs. 72 | # Usually you set "language" from the command line for these cases. 73 | language = None 74 | 75 | # There are two options for replacing |today|: either, you set today to some 76 | # non-false value, then it is used: 77 | #today = '' 78 | # Else, today_fmt is used as the format for a strftime call. 79 | #today_fmt = '%B %d, %Y' 80 | 81 | # List of patterns, relative to source directory, that match files and 82 | # directories to ignore when looking for source files. 83 | exclude_patterns = [] 84 | 85 | # The reST default role (used for this markup: `text`) to use for all 86 | # documents. 87 | #default_role = None 88 | 89 | # If true, '()' will be appended to :func: etc. cross-reference text. 90 | #add_function_parentheses = True 91 | 92 | # If true, the current module name will be prepended to all description 93 | # unit titles (such as .. function::). 94 | #add_module_names = True 95 | 96 | # If true, sectionauthor and moduleauthor directives will be shown in the 97 | # output. They are ignored by default. 98 | #show_authors = False 99 | 100 | # The name of the Pygments (syntax highlighting) style to use. 101 | pygments_style = 'sphinx' 102 | 103 | # A list of ignored prefixes for module index sorting. 104 | #modindex_common_prefix = [] 105 | 106 | # If true, keep warnings as "system message" paragraphs in the built documents. 107 | #keep_warnings = False 108 | 109 | # If true, `todo` and `todoList` produce output, else they produce nothing. 110 | todo_include_todos = False 111 | 112 | 113 | # -- Options for HTML output ---------------------------------------------- 114 | 115 | # The theme to use for HTML and HTML Help pages. See the documentation for 116 | # a list of builtin themes. 117 | html_theme = 'classic' 118 | 119 | # Theme options are theme-specific and customize the look and feel of a theme 120 | # further. For a list of options available for each theme, see the 121 | # documentation. 122 | #html_theme_options = {} 123 | 124 | # Add any paths that contain custom themes here, relative to this directory. 125 | #html_theme_path = [] 126 | 127 | # The name for this set of Sphinx documents. If None, it defaults to 128 | # " v documentation". 129 | #html_title = None 130 | 131 | # A shorter title for the navigation bar. Default is the same as html_title. 132 | #html_short_title = None 133 | 134 | # The name of an image file (relative to this directory) to place at the top 135 | # of the sidebar. 136 | #html_logo = None 137 | 138 | # The name of an image file (within the static path) to use as favicon of the 139 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 140 | # pixels large. 141 | #html_favicon = None 142 | 143 | # Add any paths that contain custom static files (such as style sheets) here, 144 | # relative to this directory. They are copied after the builtin static files, 145 | # so a file named "default.css" will overwrite the builtin "default.css". 146 | html_static_path = ['_static'] 147 | 148 | # Add any extra paths that contain custom files (such as robots.txt or 149 | # .htaccess) here, relative to this directory. These files are copied 150 | # directly to the root of the documentation. 151 | #html_extra_path = [] 152 | 153 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 154 | # using the given strftime format. 155 | #html_last_updated_fmt = '%b %d, %Y' 156 | 157 | # If true, SmartyPants will be used to convert quotes and dashes to 158 | # typographically correct entities. 159 | #html_use_smartypants = True 160 | 161 | # Custom sidebar templates, maps document names to template names. 162 | #html_sidebars = {} 163 | 164 | # Additional templates that should be rendered to pages, maps page names to 165 | # template names. 166 | #html_additional_pages = {} 167 | 168 | # If false, no module index is generated. 169 | #html_domain_indices = True 170 | 171 | # If false, no index is generated. 172 | #html_use_index = True 173 | 174 | # If true, the index is split into individual pages for each letter. 175 | #html_split_index = False 176 | 177 | # If true, links to the reST sources are added to the pages. 178 | #html_show_sourcelink = True 179 | 180 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 181 | #html_show_sphinx = True 182 | 183 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 184 | #html_show_copyright = True 185 | 186 | # If true, an OpenSearch description file will be output, and all pages will 187 | # contain a tag referring to it. The value of this option must be the 188 | # base URL from which the finished HTML is served. 189 | #html_use_opensearch = '' 190 | 191 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 192 | #html_file_suffix = None 193 | 194 | # Language to be used for generating the HTML full-text search index. 195 | # Sphinx supports the following languages: 196 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 197 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 198 | #html_search_language = 'en' 199 | 200 | # A dictionary with options for the search language support, empty by default. 201 | # Now only 'ja' uses this config value 202 | #html_search_options = {'type': 'default'} 203 | 204 | # The name of a javascript file (relative to the configuration directory) that 205 | # implements a search results scorer. If empty, the default will be used. 206 | #html_search_scorer = 'scorer.js' 207 | 208 | # Output file base name for HTML help builder. 209 | htmlhelp_basename = 'lifx-sdkdoc' 210 | 211 | # -- Options for LaTeX output --------------------------------------------- 212 | 213 | latex_elements = { 214 | # The paper size ('letterpaper' or 'a4paper'). 215 | #'papersize': 'letterpaper', 216 | 217 | # The font size ('10pt', '11pt' or '12pt'). 218 | #'pointsize': '10pt', 219 | 220 | # Additional stuff for the LaTeX preamble. 221 | #'preamble': '', 222 | 223 | # Latex figure (float) alignment 224 | #'figure_align': 'htbp', 225 | } 226 | 227 | # Grouping the document tree into LaTeX files. List of tuples 228 | # (source start file, target name, title, 229 | # author, documentclass [howto, manual, or own class]). 230 | latex_documents = [ 231 | (master_doc, 'lifx-sdk.tex', u'lifx-sdk Documentation', 232 | u'Daniel Hall', 'manual'), 233 | ] 234 | 235 | # The name of an image file (relative to this directory) to place at the top of 236 | # the title page. 237 | #latex_logo = None 238 | 239 | # For "manual" documents, if this is true, then toplevel headings are parts, 240 | # not chapters. 241 | #latex_use_parts = False 242 | 243 | # If true, show page references after internal links. 244 | #latex_show_pagerefs = False 245 | 246 | # If true, show URL addresses after external links. 247 | #latex_show_urls = False 248 | 249 | # Documents to append as an appendix to all manuals. 250 | #latex_appendices = [] 251 | 252 | # If false, no module index is generated. 253 | #latex_domain_indices = True 254 | 255 | 256 | # -- Options for manual page output --------------------------------------- 257 | 258 | # One entry per manual page. List of tuples 259 | # (source start file, name, description, authors, manual section). 260 | man_pages = [ 261 | (master_doc, 'lifx-sdk', u'lifx-sdk Documentation', 262 | [author], 1) 263 | ] 264 | 265 | # If true, show URL addresses after external links. 266 | #man_show_urls = False 267 | 268 | 269 | # -- Options for Texinfo output ------------------------------------------- 270 | 271 | # Grouping the document tree into Texinfo files. List of tuples 272 | # (source start file, target name, title, author, 273 | # dir menu entry, description, category) 274 | texinfo_documents = [ 275 | (master_doc, 'lifx-sdk', u'lifx-sdk Documentation', 276 | author, 'lifx-sdk', 'One line description of project.', 277 | 'Miscellaneous'), 278 | ] 279 | 280 | # Documents to append as an appendix to all manuals. 281 | #texinfo_appendices = [] 282 | 283 | # If false, no module index is generated. 284 | #texinfo_domain_indices = True 285 | 286 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 287 | #texinfo_show_urls = 'footnote' 288 | 289 | # If true, do not generate a @detailmenu in the "Top" node's menu. 290 | #texinfo_no_detailmenu = False 291 | 292 | 293 | # Example configuration for intersphinx: refer to the Python standard library. 294 | intersphinx_mapping = {'https://docs.python.org/': None} 295 | -------------------------------------------------------------------------------- /lifx/client.py: -------------------------------------------------------------------------------- 1 | import random 2 | import threading 3 | from datetime import datetime, timedelta 4 | from pkg_resources import iter_entry_points 5 | 6 | import network 7 | import protocol 8 | import device 9 | import util 10 | import group 11 | 12 | MISSED_POLLS = 3 13 | 14 | # Get all the mixins for the device class from the plugins available 15 | ENTRYPOINT = 'lifx.device.mixin' 16 | devicebases = [entrypoint.load() for entrypoint in iter_entry_points(ENTRYPOINT)] 17 | devicebases.append(device.Device,) 18 | Device = type('Device', tuple(devicebases), {}) 19 | del devicebases 20 | 21 | class Client(object): 22 | def __init__(self, broadcast='255.255.255.255', address='0.0.0.0', discoverpoll=60, devicepoll=5): 23 | """ 24 | The Client object is responsible for discovering lights and managing 25 | incoming and outgoing packets. This is the class most people will use to 26 | interact with the lights. 27 | 28 | :param broadcast: The address to broadcast to when discovering devices. 29 | :param address: The address to receive packet on. 30 | :param discoverpoll: The time in second between attempts to discover new bulbs. 31 | :param devicepoll: The time is seconds between polls to check if devices still respond. 32 | """ 33 | 34 | # Get Transport 35 | self._transport = network.NetworkTransport(address=address, broadcast=broadcast) 36 | 37 | # Arguments 38 | self._discoverpolltime = discoverpoll 39 | self._devicepolltime = devicepoll 40 | 41 | # Generate Random Client ID 42 | self._source = random.randrange(1, pow(2, 32) - 1) 43 | 44 | # Start packet sequence at zero 45 | self._sequence = 0 46 | 47 | # Storage for devices 48 | self._devices = {} 49 | self._groups = {} 50 | self._locations = {} 51 | 52 | # Install our service packet handler 53 | pktfilter = lambda p:p.protocol_header.pkt_type == protocol.TYPE_STATESERVICE 54 | self._transport.register_packet_handler(self._servicepacket, pktfilter) 55 | 56 | # Install the group packet handler 57 | pktfilter = lambda p:p.protocol_header.pkt_type == protocol.TYPE_STATEGROUP 58 | self._transport.register_packet_handler(self._grouppacket, pktfilter) 59 | 60 | # Install the location packet handler 61 | pktfilter = lambda p:p.protocol_header.pkt_type == protocol.TYPE_STATELOCATION 62 | self._transport.register_packet_handler(self._locationpacket, pktfilter) 63 | 64 | # Send initial discovery packet 65 | self.discover() 66 | 67 | # Start polling threads 68 | self._discoverpoll = util.RepeatTimer(discoverpoll, self.discover) 69 | self._discoverpoll.daemon = True 70 | self._discoverpoll.start() 71 | self._devicepoll = util.RepeatTimer(devicepoll, self.poll_devices) 72 | self._devicepoll.daemon = True 73 | self._devicepoll.start() 74 | 75 | def __del__(self): 76 | self._discoverpoll.cancel() 77 | self._devicepoll.cancel() 78 | 79 | def __repr__(self): 80 | return '' % repr(self.get_devices()) 81 | 82 | @property 83 | def _seq(self): 84 | seq = self._sequence 85 | self._sequence = (self._sequence + 1) % pow(2, 8) 86 | return seq 87 | 88 | def _servicepacket(self, host, port, packet): 89 | service = packet.payload.service 90 | port = packet.payload.port 91 | deviceid = packet.frame_address.target 92 | 93 | if deviceid not in self._devices and service == protocol.SERVICE_UDP: 94 | # Create a new Device 95 | new_device = Device(deviceid, host, self) 96 | 97 | # Send its own packets to it 98 | pktfilter = lambda p:( 99 | p.frame_address.target == deviceid 100 | and ( 101 | p.protocol_header.pkt_type == protocol.TYPE_ACKNOWLEDGEMENT 102 | or p.protocol_header.pkt_type == protocol.TYPE_ECHORESPONSE 103 | or p.protocol_header.pkt_type in protocol.CLASS_TYPE_STATE 104 | )) 105 | self._transport.register_packet_handler(new_device._packethandler, pktfilter) 106 | 107 | # Send the service packet directly to the device 108 | new_device._packethandler(host, port, packet) 109 | 110 | # Store it 111 | self._devices[deviceid] = new_device 112 | 113 | def _grouppacket(self, host, port, packet): 114 | # Gather Data 115 | group_id = packet.payload.group 116 | group_id_tuple = tuple(group_id) # Hashable type 117 | group_label = packet.payload.label 118 | updated_at = packet.payload.updated_at 119 | 120 | if group_id_tuple not in self._groups: 121 | # Make a new Group 122 | new_group = group.Group(group_id, self, self.by_group_id, device.Device._get_group_data) 123 | 124 | # Store it 125 | self._groups[group_id_tuple] = new_group 126 | 127 | def _locationpacket(self, host, port, packet): 128 | # Gather Data 129 | location_id = packet.payload.location 130 | location_id_tuple = tuple(location_id) # Hashable type 131 | location_label = packet.payload.label 132 | updated_at = packet.payload.updated_at 133 | 134 | if location_id_tuple not in self._locations: 135 | # Make a new Group for the location 136 | new_location = group.Group(location_id, self, self.by_location_id, device.Device._get_location_data) 137 | 138 | # Store it 139 | self._locations[location_id_tuple] = new_location 140 | 141 | def send_packet(self, *args, **kwargs): 142 | """ 143 | Sends a packet to a device. The client fills in the sequence and source 144 | parameters then calls the transport's packet send_packet. 145 | """ 146 | # Add a sequence number if a higher layer didnt 147 | if 'sequence' not in kwargs.keys(): 148 | kwargs['sequence'] = self._seq 149 | 150 | kwargs['source'] = self._source 151 | 152 | return self._transport.send_packet( 153 | *args, 154 | **kwargs 155 | ) 156 | 157 | def discover(self): 158 | """ 159 | Perform device discovery now. 160 | """ 161 | return self._transport.send_discovery(self._source, self._seq) 162 | 163 | def poll_devices(self): 164 | """ 165 | Poll all devices right now. 166 | """ 167 | poll_delta = timedelta(seconds=self._devicepolltime - 1) 168 | 169 | for device in filter(lambda x:x.seen_ago > poll_delta, self._devices.values()): 170 | device.send_poll_packet() 171 | 172 | def get_devices(self, max_seen=None): 173 | """ 174 | Get a list of all responding devices. 175 | 176 | :param max_seen: The number of seconds since the device was last seen, defaults to 3 times the devicepoll interval. 177 | """ 178 | if max_seen is None: 179 | max_seen = self._devicepolltime * MISSED_POLLS 180 | 181 | seen_delta = timedelta(seconds=max_seen) 182 | 183 | devices = filter(lambda x:x.seen_ago < seen_delta, self._devices.values()) 184 | 185 | # Sort by device id to ensure consistent ordering 186 | return sorted(devices, key=lambda k:k.id) 187 | 188 | def get_groups(self, max_seen=None): 189 | """ 190 | Get a list of all groups with responding devices. 191 | 192 | :param max_seen: The number of seconds since a device in the group was last seen, defaults to 3 times the devicepoll interval. 193 | """ 194 | devices = self.get_devices(max_seen) 195 | group_ids = set(map(lambda x:tuple(x.group_id), devices)) 196 | groups = map(lambda x:self._groups[x], group_ids) 197 | 198 | # Sort by group id to ensure consistent ordering 199 | return sorted(groups, key=lambda k:k.id) 200 | 201 | def get_locations(self, max_seen=None): 202 | """ 203 | Get a list of all locations with responding devices. 204 | 205 | :param max_seen: The number of seconds since a device in the location was last seen, defaults to 3 times the devicepoll interval. 206 | """ 207 | devices = self.get_devices(max_seen) 208 | location_ids = set(map(lambda x:tuple(x.location_id), devices)) 209 | locations = map(lambda x:self._locations[x], location_ids) 210 | 211 | # Sort by location id to ensure consistent ordering 212 | return sorted(locations, key=lambda k:k.id) 213 | 214 | def group_by_label(self, label): 215 | """ 216 | Return a list of groups with the label specified. 217 | 218 | :param by_label: The label we are looking for. 219 | :returns: list -- The groups that match criteria 220 | """ 221 | return filter(lambda d: d.label == label, self.get_groups()) 222 | 223 | def by_label(self, label): 224 | """ 225 | Return a list of devices with the label specified. 226 | 227 | :param by_label: The label we are looking for. 228 | :returns: list -- The devices that match criteria 229 | """ 230 | return filter(lambda d: d.label == label, self.get_devices()) 231 | 232 | def by_id(self, id): 233 | """ 234 | Return the device with the id specified. 235 | 236 | :param id: The device id 237 | :returns: Device -- The device with the matching id. 238 | """ 239 | return filter(lambda d: d.id == id, self.get_devices())[0] 240 | 241 | def by_power(self, power): 242 | """ 243 | Return a list of devices based on power states. 244 | 245 | :param power: True returns all devices that are on, False returns ones that are off. 246 | :returns: list -- The devices that match criteria 247 | """ 248 | return filter(lambda d: d.power == power, self.get_devices()) 249 | 250 | def by_group_id(self, group_id): 251 | """ 252 | Return a list of devices based on their group membership. 253 | 254 | :param group_id: The group id to match on each light. 255 | :returns: list -- The devices that match criteria 256 | """ 257 | return filter(lambda d: d.group_id == group_id, self.get_devices()) 258 | 259 | def by_location_id(self, location_id): 260 | """ 261 | Return a list of devices based on their group membership. 262 | 263 | :param group_id: The group id to match on each light. 264 | :returns: list -- The devices that match criteria 265 | """ 266 | return filter(lambda d: d.location_id == location_id, self.get_devices()) 267 | 268 | def __getitem__(self, key): 269 | return self.get_devices()[key] 270 | 271 | @property 272 | def devices(self): 273 | return self.get_devices() 274 | 275 | @property 276 | def groups(self): 277 | return self.get_groups() 278 | 279 | @property 280 | def locations(self): 281 | return self.get_locations() 282 | 283 | -------------------------------------------------------------------------------- /lifx/device.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import protocol 3 | from threading import Event 4 | from collections import namedtuple 5 | from lifx.color import modify_color 6 | import color 7 | import time 8 | 9 | DEFAULT_DURATION = 200 10 | DEFAULT_TIMEOUT = 2.0 11 | DEFAULT_RETRANSMITS = 10 12 | 13 | StatsTuple = namedtuple('StatsTuple', ['dropped_packets', 'sent_packets']) 14 | 15 | class DeviceTimeoutError(Exception): 16 | '''Raise when we time out waiting for a response''' 17 | def __init__(self, device, timeout, retransmits): 18 | message = "Device with id:'%s' timed out after %s seconds and %s retransmissions." % (protocol.mac_string(device.id), timeout, retransmits) 19 | 20 | super(DeviceTimeoutError, self).__init__(message) 21 | self.device = device 22 | self.timeout = timeout 23 | self.retransmits = retransmits 24 | 25 | class Device(object): 26 | def __init__(self, device_id, host, client): 27 | # Our Device 28 | self._device_id = device_id 29 | self._host = host 30 | 31 | # Services 32 | self._services = {} 33 | 34 | # Last seen time 35 | self._lastseen = datetime.now() 36 | 37 | # For sending packets 38 | self._client = client 39 | 40 | # Tools for tracking responses 41 | self._tracked = {} 42 | self._responses = {} 43 | 44 | # Stats tracking 45 | self._dropped_packets = 0 46 | self._sent_packets = 0 47 | 48 | @property 49 | def _seq(self): 50 | return self._client._seq 51 | 52 | def _packethandler(self, host, port, packet): 53 | self._seen() 54 | 55 | # If it was a service packet 56 | if packet.protocol_header.pkt_type == protocol.TYPE_STATESERVICE: 57 | self._services[packet.payload.service] = packet.payload.port 58 | 59 | # Store packet and fire events 60 | self._responses[packet.frame_address.sequence] = packet 61 | event = self._tracked.get(packet.frame_address.sequence, None) 62 | if event is not None: 63 | event.set() 64 | 65 | def _send_packet(self, *args, **kwargs): 66 | """ 67 | At this point we have most of the required arguments for the packet. The 68 | only arguments left that we need are: 69 | 70 | * ack_required 71 | * res_required 72 | * pkt_type 73 | * Arguments for the payload 74 | """ 75 | 76 | kwargs['address'] = self.host 77 | kwargs['port'] = self.get_port() 78 | kwargs['target'] = self._device_id 79 | 80 | self._sent_packets += 1 81 | 82 | return self._client.send_packet( 83 | *args, 84 | **kwargs 85 | ) 86 | 87 | def _block_for_response(self, *args, **kwargs): 88 | return self._block_for(False, True, *args, **kwargs) 89 | 90 | def _block_for_ack(self, *args, **kwargs): 91 | return self._block_for(True, False, *args, **kwargs) 92 | 93 | def _block_for(self, need_ack, need_res, *args, **kwargs): 94 | """ 95 | Send a packet and block waiting for the replies. 96 | 97 | Only needs the type and an optional payload. 98 | """ 99 | if need_ack and need_res: 100 | raise NotImplemented('Waiting for both acknowledgement and response not yet supported.') 101 | 102 | sequence = self._seq 103 | timeout = kwargs.get('timeout', DEFAULT_TIMEOUT) 104 | sub_timeout = timeout / DEFAULT_RETRANSMITS 105 | 106 | for i in range(1, DEFAULT_RETRANSMITS): 107 | if i != 1: 108 | self._dropped_packets += 1 109 | 110 | e = Event() 111 | self._tracked[sequence] = e 112 | 113 | self._send_packet( 114 | ack_required=need_ack, 115 | res_required=need_res, 116 | sequence=sequence, 117 | *args, 118 | **kwargs 119 | ) 120 | 121 | # If we don't care about a response, don't block at all 122 | if not (need_ack or need_res): 123 | return None 124 | 125 | if e.wait(sub_timeout): 126 | response = self._responses[sequence] 127 | 128 | # TODO: Check if the response was actually what we expected 129 | 130 | if need_res: 131 | return response.payload 132 | else: 133 | return True 134 | 135 | # We did get a response 136 | raise DeviceTimeoutError(self, timeout, DEFAULT_RETRANSMITS) 137 | 138 | 139 | def _get_group_data(self): 140 | """ 141 | Called by the group object so it can see the updated_at from the group 142 | """ 143 | return self._block_for_response(pkt_type=protocol.TYPE_GETGROUP) 144 | 145 | def _get_location_data(self): 146 | """ 147 | Called by the group object so it can see the updated_at from the location 148 | """ 149 | return self._block_for_response(pkt_type=protocol.TYPE_GETLOCATION) 150 | 151 | def send_poll_packet(self): 152 | """ 153 | Send a poll packet to the device, without waiting for a response. The 154 | response will be received later and will update the time we last saw 155 | the bulb. 156 | """ 157 | return self._send_packet( 158 | ack_required=False, 159 | res_required=True, 160 | pkt_type=protocol.TYPE_GETSERVICE, 161 | ) 162 | 163 | def _seen(self): 164 | self._lastseen = datetime.now() 165 | 166 | def __repr__(self): 167 | return u'' % (protocol.mac_string(self._device_id), repr(self.label)) 168 | 169 | def get_port(self, service_id=protocol.SERVICE_UDP): 170 | """ 171 | Get the port for a service, by default the UDP service. 172 | 173 | :param service_id: The service whose port we are fetching. 174 | """ 175 | return self._services[service_id] 176 | 177 | @property 178 | def stats(self): 179 | return StatsTuple( 180 | dropped_packets=self._dropped_packets, 181 | sent_packets=self._sent_packets, 182 | ) 183 | 184 | @property 185 | def group_id(self): 186 | """ 187 | The id of the group that the Device is in. Read Only. 188 | """ 189 | response = self._get_group_data() 190 | return response.group 191 | 192 | @property 193 | def location_id(self): 194 | """ 195 | The id of the group that the Device is in. Read Only. 196 | """ 197 | response = self._get_location_data() 198 | return response.location 199 | 200 | @property 201 | def udp_port(self): 202 | """ 203 | The port of the UDP service. Read Only. 204 | """ 205 | return self.get_port(protocol.SERVICE_UDP) 206 | 207 | @property 208 | def seen_ago(self): 209 | """ 210 | The time in seconds since we last saw a packet from the device. Read Only. 211 | """ 212 | return datetime.now() - self._lastseen 213 | 214 | @property 215 | def host(self): 216 | """ 217 | The ip address of the device. Read Only. 218 | """ 219 | return self._host 220 | 221 | @property 222 | def host_firmware(self): 223 | """ 224 | The version string representing the firmware version. 225 | """ 226 | response = self._block_for_response(pkt_type=protocol.TYPE_GETHOSTFIRMWARE) 227 | return protocol.version_string(response.version) 228 | 229 | @property 230 | def wifi_firmware(self): 231 | """ 232 | The version string representing the firmware version. 233 | """ 234 | response = self._block_for_response(pkt_type=protocol.TYPE_GETWIFIFIRMWARE) 235 | return protocol.version_string(response.version) 236 | 237 | @property 238 | def id(self): 239 | """ 240 | The device id. Read Only. 241 | """ 242 | return self._device_id 243 | 244 | @property 245 | def latency(self): 246 | """ 247 | The latency to the device. Read Only. 248 | """ 249 | ping_payload = bytearray('\x00' * 64) 250 | start = time.time() 251 | response = self._block_for_response(ping_payload, pkt_type=protocol.TYPE_ECHOREQUEST) 252 | end = time.time() 253 | return end - start 254 | 255 | @property 256 | def label(self): 257 | """ 258 | The label for the device, setting this will change the label on the device. 259 | """ 260 | response = self._block_for_response(pkt_type=protocol.TYPE_GETLABEL) 261 | return protocol.bytes_to_label(response.label) 262 | 263 | @label.setter 264 | def label(self, label): 265 | newlabel = bytearray(label.encode('utf-8')[0:protocol.LABEL_MAXLEN]) 266 | 267 | return self._block_for_ack(newlabel, pkt_type=protocol.TYPE_SETLABEL) 268 | 269 | def fade_power(self, power, duration=DEFAULT_DURATION): 270 | """ 271 | Transition to another power state slowly. 272 | 273 | :param power: The new power state 274 | :param duration: The number of milliseconds to perform the transition over. 275 | """ 276 | if power: 277 | msgpower = protocol.UINT16_MAX 278 | else: 279 | msgpower = 0 280 | 281 | return self._block_for_ack(msgpower, duration, pkt_type=protocol.TYPE_LIGHT_SETPOWER) 282 | 283 | def power_toggle(self, duration=DEFAULT_DURATION): 284 | """ 285 | Transition to the opposite power state slowly. 286 | 287 | :param duration: The number of milliseconds to perform the transition over. 288 | """ 289 | self.fade_power(not self.power, duration) 290 | 291 | @property 292 | def power(self): 293 | """ 294 | The power state of the device. Set to False to turn of and True to turn on. 295 | """ 296 | response = self._block_for_response(pkt_type=protocol.TYPE_GETPOWER) 297 | if response.level > 0: 298 | return True 299 | else: 300 | return False 301 | 302 | @power.setter 303 | def power(self, power): 304 | self.fade_power(power) 305 | 306 | def fade_color(self, newcolor, duration=DEFAULT_DURATION): 307 | """ 308 | Transition the light to a new color. 309 | 310 | :param newcolor: The HSBK tuple of the new color to transition to 311 | :param duration: The number of milliseconds to perform the transition over. 312 | """ 313 | colormsg = color.message_from_color(newcolor) 314 | return self._block_for_ack( 315 | 0, 316 | colormsg.hue, 317 | colormsg.saturation, 318 | colormsg.brightness, 319 | colormsg.kelvin, 320 | duration, 321 | pkt_type=protocol.TYPE_LIGHT_SETCOLOR 322 | ) 323 | 324 | @property 325 | def color(self): 326 | """ 327 | The color the device is currently set to. Set this value to change the 328 | color of the bulb all at once. 329 | """ 330 | response = self._block_for_response(pkt_type=protocol.TYPE_LIGHT_GET) 331 | return color.color_from_message(response) 332 | 333 | @color.setter 334 | def color(self, newcolor): 335 | self.fade_color(newcolor) 336 | 337 | # Helpers to change the color on the bulb 338 | @property 339 | def hue(self): 340 | """ 341 | The hue value of the color the bulb is set to. Set this to alter only 342 | the Hue. 343 | """ 344 | return self.color.hue 345 | 346 | @hue.setter 347 | def hue(self, hue): 348 | self.color = modify_color(self.color, hue=hue) 349 | 350 | @property 351 | def saturation(self): 352 | """ 353 | The saturation value the bulb is currently set to. Set this to alter 354 | only the saturation. 355 | """ 356 | return self.color.saturation 357 | 358 | @saturation.setter 359 | def saturation(self, saturation): 360 | self.color = modify_color(self.color, saturation=saturation) 361 | 362 | @property 363 | def brightness(self): 364 | """ 365 | The brightness value the bulb is currently set to. Set this to alter 366 | only the current brightness of the bulb. 367 | """ 368 | return self.color.brightness 369 | 370 | @brightness.setter 371 | def brightness(self, brightness): 372 | self.color = modify_color(self.color, brightness=brightness) 373 | 374 | @property 375 | def kelvin(self): 376 | """ 377 | The kelvin value the bulb is currently set to. Set this to alter 378 | only the current kelvin of the bulb. 379 | """ 380 | return self.color.kelvin 381 | 382 | @kelvin.setter 383 | def kelvin(self, kelvin): 384 | self.color = modify_color(self.color, kelvin=kelvin) 385 | 386 | -------------------------------------------------------------------------------- /lifx/protocol.py: -------------------------------------------------------------------------------- 1 | from bitstruct import unpack, pack, byteswap, calcsize 2 | from binascii import hexlify 3 | from collections import namedtuple 4 | from pkg_resources import iter_entry_points 5 | from datetime import datetime 6 | 7 | UINT16_MAX = pow(2, 16) - 1 8 | LABEL_MAXLEN = 32 9 | ENTRYPOINT = 'lifx.protocol' 10 | 11 | # Packet tuple 12 | lifx_packet = namedtuple('lifx_packet', ['frame_header', 'frame_address', 'protocol_header', 'payload']) 13 | 14 | # Header Descriptions 15 | frame_header = { 16 | 'format': 'u16u2u1u1u12u32', 17 | 'byteswap': '224', 18 | 'fields': namedtuple('frame_header', [ 19 | 'size', 20 | 'origin', 21 | 'tagged', 22 | 'addressable', 23 | 'protocol', 24 | 'source' 25 | ]), 26 | } 27 | 28 | frame_address = { 29 | 'format': 'u64u48u6u1u1u8', 30 | 'byteswap': '8611', 31 | 'fields': namedtuple('frame_address', [ 32 | 'target', 33 | 'reserved1', 34 | 'reserved2', 35 | 'ack_required', 36 | 'res_required', 37 | 'sequence' 38 | ]), 39 | } 40 | 41 | protocol_header = { 42 | 'format': 'u64u16u16', 43 | 'byteswap': '822', 44 | 'fields': namedtuple('protocol_header', [ 45 | 'reserved1', 46 | 'pkt_type', 47 | 'reserved2' 48 | ]), 49 | } 50 | 51 | # Device Messages 52 | TYPE_GETSERVICE = 2 53 | TYPE_STATESERVICE = 3 54 | TYPE_GETHOSTINFO = 12 55 | TYPE_STATEHOSTINFO = 13 56 | TYPE_GETHOSTFIRMWARE = 14 57 | TYPE_STATEHOSTFIRMWARE = 15 58 | TYPE_GETWIFIINFO = 16 59 | TYPE_STATEWIFIINFO = 17 60 | TYPE_GETWIFIFIRMWARE = 18 61 | TYPE_STATEWIFIFIRMWARE = 19 62 | TYPE_GETPOWER = 20 63 | TYPE_SETPOWER = 21 64 | TYPE_STATEPOWER = 22 65 | TYPE_GETLABEL = 23 66 | TYPE_SETLABEL = 24 67 | TYPE_STATELABEL = 25 68 | TYPE_GETVERSION = 32 69 | TYPE_STATEVERSION = 33 70 | TYPE_GETINFO = 34 71 | TYPE_STATEINFO = 35 72 | TYPE_GETLOCATION = 48 73 | TYPE_STATELOCATION = 50 74 | TYPE_GETGROUP = 51 75 | TYPE_STATEGROUP = 53 76 | TYPE_ACKNOWLEDGEMENT = 45 77 | TYPE_ECHOREQUEST = 58 78 | TYPE_ECHORESPONSE = 59 79 | 80 | # Light Messages 81 | TYPE_LIGHT_GET = 101 82 | TYPE_LIGHT_SETCOLOR = 102 83 | TYPE_LIGHT_STATE = 107 84 | TYPE_LIGHT_GETPOWER = 116 85 | TYPE_LIGHT_SETPOWER = 117 86 | TYPE_LIGHT_STATEPOWER = 118 87 | 88 | # Message Classes 89 | CLASS_TYPE_GET = ( 90 | TYPE_GETSERVICE, 91 | TYPE_GETHOSTINFO, 92 | TYPE_GETHOSTFIRMWARE, 93 | TYPE_GETWIFIINFO, 94 | TYPE_GETWIFIFIRMWARE, 95 | TYPE_GETPOWER, 96 | TYPE_GETLABEL, 97 | TYPE_GETVERSION, 98 | TYPE_GETINFO, 99 | TYPE_GETLOCATION, 100 | TYPE_GETGROUP, 101 | TYPE_LIGHT_GET, 102 | TYPE_LIGHT_GETPOWER, 103 | ) 104 | 105 | CLASS_TYPE_SET = ( 106 | TYPE_SETPOWER, 107 | TYPE_SETLABEL, 108 | TYPE_LIGHT_SETCOLOR, 109 | TYPE_LIGHT_SETPOWER, 110 | ) 111 | 112 | CLASS_TYPE_STATE = ( 113 | TYPE_STATESERVICE, 114 | TYPE_STATEHOSTINFO, 115 | TYPE_STATEHOSTFIRMWARE, 116 | TYPE_STATEWIFIINFO, 117 | TYPE_STATEWIFIFIRMWARE, 118 | TYPE_STATEPOWER, 119 | TYPE_STATELABEL, 120 | TYPE_STATEVERSION, 121 | TYPE_STATEINFO, 122 | TYPE_STATELOCATION, 123 | TYPE_STATEGROUP, 124 | TYPE_LIGHT_STATE, 125 | TYPE_LIGHT_STATEPOWER, 126 | ) 127 | 128 | CLASS_TYPE_OTHER = ( 129 | TYPE_ACKNOWLEDGEMENT, 130 | TYPE_ECHOREQUEST, 131 | TYPE_ECHORESPONSE, 132 | ) 133 | 134 | # Service Types 135 | SERVICE_UDP = 1 136 | SERVICE_RESERVED1 = 2 137 | SERVICE_RESERVED2 = 3 138 | SERVICE_RESERVED3 = 4 139 | SERVICE_RESERVED4 = 5 140 | 141 | messages = { 142 | TYPE_GETSERVICE: { 143 | 'format': '', 144 | 'byteswap': '', 145 | 'fields': namedtuple('payload_getservice', [ 146 | ]), 147 | }, 148 | TYPE_STATESERVICE: { 149 | 'format': 'u8u32', 150 | 'byteswap': '14', 151 | 'fields': namedtuple('payload_stateservice', [ 152 | 'service', 153 | 'port', 154 | ]), 155 | }, 156 | TYPE_GETHOSTINFO: { 157 | 'format': '', 158 | 'byteswap': '', 159 | 'fields': namedtuple('payload_gethostinfo', [ 160 | ]), 161 | }, 162 | TYPE_STATEHOSTINFO: { 163 | 'format': 'u32u32u32u16', 164 | 'byteswap': '4442', 165 | 'fields': namedtuple('payload_statehostinfo', [ 166 | 'signal', 167 | 'tx', 168 | 'rx', 169 | 'reserved', 170 | ]), 171 | }, 172 | TYPE_GETHOSTFIRMWARE: { 173 | 'format': '', 174 | 'byteswap': '', 175 | 'fields': namedtuple('payload_gethostfirmware', [ 176 | ]), 177 | }, 178 | TYPE_STATEHOSTFIRMWARE: { 179 | 'format': 'u64u64u32', 180 | 'byteswap': '884', 181 | 'fields': namedtuple('payload_statehostfirmware', [ 182 | 'build', 183 | 'reserved', 184 | 'version', 185 | ]), 186 | }, 187 | TYPE_GETWIFIINFO: { 188 | 'format': '', 189 | 'byteswap': '', 190 | 'fields': namedtuple('payload_getwifiinfo', [ 191 | ]), 192 | }, 193 | TYPE_STATEWIFIINFO: { 194 | 'format': 'u32u32u32u16', 195 | 'byteswap': '4442', 196 | 'fields': namedtuple('payload_statewifiinfo', [ 197 | 'signal', 198 | 'tx', 199 | 'rx', 200 | 'reserved', 201 | ]), 202 | }, 203 | TYPE_GETWIFIFIRMWARE: { 204 | 'format': '', 205 | 'byteswap': '', 206 | 'fields': namedtuple('payload_wififirmware', [ 207 | ]), 208 | }, 209 | TYPE_STATEWIFIFIRMWARE: { 210 | 'format': 'u64u64u32', 211 | 'byteswap': '884', 212 | 'fields': namedtuple('payload_statewififirmware', [ 213 | 'build', 214 | 'reserved', 215 | 'version', 216 | ]), 217 | }, 218 | TYPE_GETPOWER: { 219 | 'format': '', 220 | 'byteswap': '', 221 | 'fields': namedtuple('payload_getpower', [ 222 | ]), 223 | }, 224 | TYPE_SETPOWER: { 225 | 'format': 'u16', 226 | 'byteswap': '2', 227 | 'fields': namedtuple('payload_setpower', [ 228 | 'level', 229 | ]), 230 | }, 231 | TYPE_STATEPOWER: { 232 | 'format': 'u16', 233 | 'byteswap': '2', 234 | 'fields': namedtuple('payload_statepower', [ 235 | 'level', 236 | ]), 237 | }, 238 | TYPE_GETLABEL: { 239 | 'format': '', 240 | 'byteswap': '', 241 | 'fields': namedtuple('payload_getlabel', [ 242 | ]), 243 | }, 244 | TYPE_SETLABEL: { 245 | 'format': 'b256', 246 | 'byteswap': '1' * 32, 247 | 'fields': namedtuple('payload_setlabel', [ 248 | 'label', 249 | ]), 250 | }, 251 | TYPE_STATELABEL: { 252 | 'format': 'b256', 253 | 'byteswap': '1' * 32, 254 | 'fields': namedtuple('payload_statelabel', [ 255 | 'label', 256 | ]), 257 | }, 258 | TYPE_GETVERSION: { 259 | 'format': '', 260 | 'byteswap': '', 261 | 'fields': namedtuple('payload_getversion', [ 262 | ]), 263 | }, 264 | TYPE_STATEVERSION: { 265 | 'format': 'u32u32u32', 266 | 'byteswap': '444', 267 | 'fields': namedtuple('payload_stateversion', [ 268 | 'vendor', 269 | 'product', 270 | 'version', 271 | ]), 272 | }, 273 | TYPE_GETINFO: { 274 | 'format': '', 275 | 'byteswap': '', 276 | 'fields': namedtuple('payload_getinfo', [ 277 | ]), 278 | }, 279 | TYPE_STATEINFO: { 280 | 'format': 'u64u64u64', 281 | 'byteswap': '888', 282 | 'fields': namedtuple('payload_stateinfo', [ 283 | 'time', 284 | 'uptime', 285 | 'downtime', 286 | ]), 287 | }, 288 | TYPE_ACKNOWLEDGEMENT: { 289 | 'format': '', 290 | 'byteswap': '', 291 | 'fields': namedtuple('payload_acknowledgement', [ 292 | ]), 293 | }, 294 | TYPE_GETLOCATION: { 295 | 'format': '', 296 | 'byteswap': '', 297 | 'fields': namedtuple('payload_getlocation', [ 298 | ]), 299 | }, 300 | TYPE_STATELOCATION: { 301 | 'format': 'b128b256u64', 302 | 'byteswap': '1' * 16 + '1' * 32 + '8', 303 | 'fields': namedtuple('payload_statelocation', [ 304 | 'location', 305 | 'label', 306 | 'updated_at', 307 | ]), 308 | }, 309 | TYPE_GETGROUP: { 310 | 'format': '', 311 | 'byteswap': '', 312 | 'fields': namedtuple('payload_getgroup', [ 313 | ]), 314 | }, 315 | TYPE_STATEGROUP: { 316 | 'format': 'b128b256u64', 317 | 'byteswap': '1' * 16 + '1' * 32 + '8', 318 | 'fields': namedtuple('payload_stategroup', [ 319 | 'group', 320 | 'label', 321 | 'updated_at', 322 | ]), 323 | }, 324 | TYPE_ECHOREQUEST: { 325 | 'format': 'b512', 326 | 'byteswap': '1' * 64, 327 | 'fields': namedtuple('payload_echorequest', [ 328 | 'payload', 329 | ]), 330 | }, 331 | TYPE_ECHORESPONSE: { 332 | 'format': 'b512', 333 | 'byteswap': '1' * 64, 334 | 'fields': namedtuple('payload_echoresponse', [ 335 | 'payload', 336 | ]), 337 | }, 338 | TYPE_LIGHT_GET: { 339 | 'format': '', 340 | 'byteswap': '', 341 | 'fields': namedtuple('payload_light_get', [ 342 | ]), 343 | }, 344 | TYPE_LIGHT_SETCOLOR: { 345 | 'format': 'u8u16u16u16u16u32', 346 | 'byteswap': '122224', 347 | 'fields': namedtuple('payload_light_setcolor', [ 348 | 'reserved', 349 | 'hue', 350 | 'saturation', 351 | 'brightness', 352 | 'kelvin', 353 | 'duration', 354 | ]), 355 | }, 356 | TYPE_LIGHT_STATE: { 357 | 'format': 'u16u16u16u16s16u16b256u64', 358 | 'byteswap': '222222' + '1' * 32 + '8', 359 | 'fields': namedtuple('payload_light_state', [ 360 | 'hue', 361 | 'saturation', 362 | 'brightness', 363 | 'kelvin', 364 | 'reserved1', 365 | 'power', 366 | 'label', 367 | 'reserved2', 368 | ]), 369 | }, 370 | TYPE_LIGHT_GETPOWER: { 371 | 'format': '', 372 | 'byteswap': '', 373 | 'fields': namedtuple('payload_light_getpower', [ 374 | ]), 375 | }, 376 | TYPE_LIGHT_SETPOWER: { 377 | 'format': 'u16u32', 378 | 'byteswap': '24', 379 | 'fields': namedtuple('payload_light_setpower', [ 380 | 'level', 381 | 'duration', 382 | ]), 383 | }, 384 | TYPE_LIGHT_STATEPOWER: { 385 | 'format': 'u16', 386 | 'byteswap': '2', 387 | 'fields': namedtuple('payload_light_statepower', [ 388 | 'level', 389 | ]), 390 | }, 391 | } 392 | 393 | def mac_string(device_id): 394 | """ 395 | Converts a device id into a mac address hex string 396 | 397 | :param device_id: The device id 398 | :returns: str -- The mac address represented as a string 399 | """ 400 | return hexlify(byteswap('6', pack('u48', device_id))) 401 | 402 | def timestamp_datetime(timestamp): 403 | """ 404 | Converts a timestamp from the device to a python datetime value 405 | """ 406 | return datetime.fromtimestamp(timestamp / 1000000000.0) 407 | 408 | def version_string(version): 409 | """ 410 | Converts a version number into a version string 411 | 412 | :param version: The version number from the firmware 413 | :returns str -- The version string 414 | """ 415 | major_version = version >> 0x10 416 | minor_version = version & 0xFFFF 417 | return '%d.%d' % (major_version, minor_version) 418 | 419 | def bytes_to_label(label_bytes): 420 | """ 421 | Takes the bytes from a TYPE_STATELABEL packet removes the NUL char and 422 | and everything after, then converts it to unicode. 423 | 424 | :param label_bytes: The bytes from the TYPE_STATELABEL packet 425 | :returns: unicode -- The label of the device 426 | """ 427 | strlen = label_bytes.find('\x00') 428 | return label_bytes[0:strlen].decode('utf-8') 429 | 430 | def pack_section(section, *args): 431 | """ 432 | Packs bytes into a header including the swap to little-endian 433 | 434 | :param section: The definition of the format and byteswap for this section 435 | :param \*args: Values to include in the section in the order they are in the format 436 | :returns: bytearray -- The packed bytes of the section 437 | """ 438 | return byteswap(section['byteswap'], pack(section['format'], *args)) 439 | 440 | def unpack_section(section, data): 441 | """ 442 | Unpacks bytes into data, including the endian swap 443 | 444 | :param section: The definition of the format, byteswap and namedtuple for this section 445 | :param data: The bytes to unpack into the tuple 446 | :returns: namedtuple -- A namedtuple containing the data from the section 447 | """ 448 | 449 | # Bitstruct only takes byte arrays, some things give us strings 450 | if type(data) != bytearray: 451 | data = bytearray(data) 452 | 453 | unpacked = unpack(section['format'], byteswap(section['byteswap'], data)) 454 | return section['fields'](*unpacked) 455 | 456 | def section_size(section): 457 | """ 458 | Returns the size of a section 459 | 460 | :param section: The definition of the format for this section 461 | :returns: int -- An integer representing the size in bits of the section 462 | """ 463 | return calcsize(section['format']) 464 | 465 | def make_packet(*args, **kwargs): 466 | """ 467 | Builds a packet from data supplied, required arguments depends on the packet type 468 | """ 469 | 470 | source = kwargs['source'] 471 | target = kwargs['target'] 472 | ack_required = kwargs['ack_required'] 473 | res_required = kwargs['res_required'] 474 | sequence = kwargs['sequence'] 475 | pkt_type = kwargs['pkt_type'] 476 | 477 | # Frame header 478 | packet_size = ( section_size(frame_header) 479 | + section_size(frame_address) 480 | + section_size(protocol_header) 481 | + section_size(messages[pkt_type]) ) / 8 482 | 483 | origin = 0 # Origin is always zero 484 | tagged = 1 if target is None else 0 485 | addressable = 1 # Addressable is always one 486 | protocol = 1024 # Only protocol 1024 so far 487 | 488 | frame_header_data = pack_section( 489 | frame_header, 490 | packet_size, 491 | origin, 492 | tagged, 493 | addressable, 494 | protocol, 495 | source, 496 | ) 497 | 498 | # Frame Address 499 | target = 0 if target is None else target 500 | res_required = 1 if res_required else 0 501 | ack_required = 1 if ack_required else 0 502 | 503 | frame_address_data = pack_section( 504 | frame_address, 505 | target, 506 | 0, # Reserved 507 | 0, # Reserved 508 | ack_required, 509 | res_required, 510 | sequence, 511 | ) 512 | 513 | # Protocol Header 514 | protocol_header_data = pack_section( 515 | protocol_header, 516 | 0, # Reserved 517 | pkt_type, 518 | 0, # Reserved 519 | ) 520 | 521 | # Payload 522 | payload_data = pack_section( 523 | messages[pkt_type], 524 | *args 525 | ) 526 | 527 | packet = frame_header_data + frame_address_data + protocol_header_data + payload_data 528 | 529 | return packet 530 | 531 | 532 | def parse_packet(data): 533 | """ 534 | Takes packet data as composes it into several namedtuple objects 535 | 536 | :param data: Byte data for the packet to be parsed 537 | :returns: namedtuple -- A named tuple representing the packet, with nested namedtuples for each header and the payload 538 | """ 539 | # Frame Header 540 | frame_header_size = section_size(frame_header) / 8 541 | frame_header_data = data[0:frame_header_size] 542 | 543 | frame_header_struct = unpack_section( 544 | frame_header, 545 | frame_header_data 546 | ) 547 | 548 | if frame_header_struct.size != len(data): 549 | return None 550 | 551 | # Frame Address 552 | frame_address_size = section_size(frame_address) / 8 553 | frame_address_start = frame_header_size 554 | frame_address_end = frame_header_size + frame_address_size 555 | frame_address_data = data[frame_address_start:frame_address_end] 556 | frame_address_struct = unpack_section( 557 | frame_address, 558 | frame_address_data 559 | ) 560 | 561 | # Protocol Header 562 | protocol_header_size = section_size(protocol_header) / 8 563 | protocol_header_start = frame_address_end 564 | protocol_header_end = protocol_header_start + protocol_header_size 565 | protocol_header_data = data[protocol_header_start:protocol_header_end] 566 | protocol_header_struct = unpack_section( 567 | protocol_header, 568 | protocol_header_data 569 | ) 570 | 571 | # Payload 572 | payload_start = protocol_header_end 573 | if protocol_header_struct.pkt_type in messages.keys(): 574 | payload = messages[protocol_header_struct.pkt_type] 575 | payload_size = section_size(payload) / 8 576 | payload_end = payload_start + payload_size 577 | payload_data = data[payload_start:payload_end] 578 | payload_struct = unpack_section( 579 | payload, 580 | payload_data 581 | ) 582 | else: 583 | payload_size = frame_header_struct.size - payload_start 584 | payload_end = payload_start + payload_size 585 | payload_struct = data[payload_start:payload_end] 586 | 587 | 588 | return lifx_packet( 589 | frame_header_struct, 590 | frame_address_struct, 591 | protocol_header_struct, 592 | payload_struct 593 | ) 594 | 595 | def discovery_packet(source, sequence): 596 | """ 597 | Helper function for building a discovery packet easily 598 | 599 | :param source: The source field to put into the frame header 600 | :param sequence: The wrap around sequence number for the frame address header 601 | :returns: bytearray -- The discovery packet represented as bytes 602 | """ 603 | return make_packet( 604 | source=source, 605 | target=None, 606 | ack_required=False, 607 | res_required=True, 608 | sequence=sequence, 609 | pkt_type=TYPE_GETSERVICE, 610 | ) 611 | 612 | # Load plugins that provide new messages 613 | for entrypoint in iter_entry_points(ENTRYPOINT): 614 | protocol_module = entrypoint.load() 615 | 616 | CLASS_TYPE_GET += getattr(protocol_module, 'CLASS_TYPE_GET', ()) 617 | CLASS_TYPE_SET += getattr(protocol_module, 'CLASS_TYPE_SET', ()) 618 | CLASS_TYPE_STATE += getattr(protocol_module, 'CLASS_TYPE_STATE', ()) 619 | CLASS_TYPE_OTHER += getattr(protocol_module, 'CLASS_TYPE_OTHER', ()) 620 | 621 | new_messages = getattr(protocol_module, 'messages', {}) 622 | messages.update(new_messages) 623 | 624 | --------------------------------------------------------------------------------