├── .gitignore ├── README.md ├── lego_wireless ├── __init__.py ├── __main__.py ├── constants.py ├── enums.py ├── hub.py ├── hub_io.py ├── manager.py ├── messages.py └── signals.py └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | # PyCharm IDE files 127 | /.idea 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lego Wireless Protocol for Python 2 | 3 | A spare-time implementation of the Lego Wireless Protocol for Python, to support a pet project. 4 | 5 | See `lego_wireless/__main__.py` for a usage example. 6 | -------------------------------------------------------------------------------- /lego_wireless/__init__.py: -------------------------------------------------------------------------------- 1 | from .manager import HubManager 2 | -------------------------------------------------------------------------------- /lego_wireless/__main__.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import logging 3 | import threading 4 | 5 | from . import signals 6 | from .manager import HubManager 7 | from lego_wireless.hub_io import LEDLight 8 | from lego_wireless.hub_io import TrainMotor 9 | 10 | hubs_seen = set() 11 | 12 | 13 | def hub_discovered(sender, hub): 14 | if hub not in hubs_seen: 15 | hubs_seen.add(hub) 16 | print("Connecting Hub IO, %s", hub.mac_address) 17 | signals.hub_io_connected.connect(hub_io_connected, sender=hub) 18 | hub.connect() 19 | 20 | 21 | def hub_io_connected(sender, hub_io): 22 | print("Let's go!") 23 | if isinstance(hub_io, TrainMotor): 24 | hub_io.set_speed(100) 25 | if isinstance(hub_io, LEDLight): 26 | hub_io.set_brightness(100) 27 | 28 | 29 | def main(): 30 | hub_manager = HubManager("hci0") 31 | atexit.register(hub_manager.stop) 32 | 33 | signals.hub_discovered.connect(hub_discovered, sender=hub_manager) 34 | 35 | hub_manager_thread = threading.Thread(target=hub_manager.run) 36 | hub_manager_thread.start() 37 | hub_manager.start_discovery() 38 | 39 | 40 | if __name__ == "__main__": 41 | logging.basicConfig(level=logging.DEBUG) 42 | main() 43 | -------------------------------------------------------------------------------- /lego_wireless/constants.py: -------------------------------------------------------------------------------- 1 | SERVICE_UUID = "00001623-1212-efde-1623-785feabcd123" 2 | CHARACTERISTIC_UUID = "00001624-1212-efde-1623-785feabcd123" 3 | -------------------------------------------------------------------------------- /lego_wireless/enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from gettext import gettext as _ 3 | 4 | 5 | class MessageType(enum.IntEnum): 6 | HubProperties = 0x01 7 | HubActions = 0x02 8 | HubAlerts = 0x03 9 | HubAttachedIO = 0x04 10 | ErrorMessage = 0x05 11 | PortOutput = 0x81 12 | 13 | 14 | class OutputSubCommand(enum.IntEnum): 15 | StartPower = 0x01 16 | 17 | 18 | class IOType(enum.IntEnum): 19 | Motor = 0x0001 20 | TrainMotor = 0x0002 21 | Button = 0x0005 22 | LEDLight = 0x0008 23 | Voltage = 0x0014 24 | Current = 0x0015 25 | PiezoTone = 0x0016 26 | RGBLight = 0x0017 27 | 28 | 29 | class HubAttachedIOEvent(enum.IntEnum): 30 | DetachedIO = 0x00 31 | AttachedIO = 0x01 32 | AttachedVirtualIO = 0x02 33 | 34 | 35 | class ColorNo(enum.IntEnum): 36 | Off = 0 37 | Magenta = 1 38 | Purple = 2 39 | Blue = 3 40 | Cyan = 4 41 | Turquoise = 5 42 | Green = 6 43 | Yellow = 7 44 | Orange = 8 45 | Red = 9 46 | White = 10 47 | 48 | 49 | color_names = { 50 | ColorNo.Off: _("off"), 51 | ColorNo.Magenta: _("magenta"), 52 | ColorNo.Purple: _("purple"), 53 | ColorNo.Blue: _("blue"), 54 | ColorNo.Cyan: _("cyan"), 55 | ColorNo.Turquoise: _("turquoise"), 56 | ColorNo.Green: _("green"), 57 | ColorNo.Yellow: _("yellow"), 58 | ColorNo.Orange: _("orange"), 59 | ColorNo.Red: _("red"), 60 | ColorNo.White: _("white"), 61 | } 62 | 63 | 64 | class HubProperty(enum.IntEnum): 65 | AdvertisingName = 0x01 66 | Button = 0x02 67 | FWVersion = 0x03 68 | HWVersion = 0x04 69 | RSSI = 0x05 70 | BatteryVoltage = 0x06 71 | BatteryType = 0x07 72 | ManufacturerName = 0x08 73 | RadioFirmwareVersion = 0x09 74 | WirelessProtocolVersion = 0x0A 75 | SystemTypeID = 0x0B 76 | HWNetworkID = 0x0C 77 | PrimaryMACAddress = 0x0D 78 | SecondaryMACAddress = 0x0E 79 | HardwareNetworkFamily = 0x0F 80 | 81 | 82 | class HubPropertyOperation(enum.IntEnum): 83 | Set = 0x01 84 | EnableUpdates = 0x02 85 | DisableUpdates = 0x03 86 | Reset = 0x04 87 | RequestUpdate = 0x05 88 | Update = 0x06 89 | 90 | 91 | class Startup(enum.IntEnum): 92 | BufferIfNecessary = 0b0000 93 | ExecuteImmediately = 0b0001 94 | 95 | 96 | class Completion(enum.IntEnum): 97 | NoAction = 0b0000 98 | CommandFeedback = 0b0001 99 | 100 | 101 | class MotorControl(enum.IntEnum): 102 | Float = 0 103 | Brake = 127 104 | 105 | 106 | class ErrorCode(enum.IntEnum): 107 | ACK = 0x01 108 | MACK = 0x02 109 | BufferOverflow = 0x03 110 | Timeout = 0x04 111 | CommandNotRecognized = 0x05 112 | InvalidUse = 0x06 113 | Overcurrent = 0x07 114 | InternalError = 0x08 115 | -------------------------------------------------------------------------------- /lego_wireless/hub.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | import threading 4 | import typing 5 | 6 | import gatt 7 | 8 | from lego_wireless import signals 9 | from lego_wireless.constants import CHARACTERISTIC_UUID 10 | from lego_wireless.enums import HubAttachedIOEvent 11 | from lego_wireless.enums import HubProperty 12 | from lego_wireless.enums import HubPropertyOperation 13 | from lego_wireless.enums import MessageType 14 | from lego_wireless.hub_io import HubIO 15 | from lego_wireless.hub_io import LEDLight 16 | from lego_wireless.hub_io import RGBLight 17 | from lego_wireless.hub_io import TrainMotor 18 | from lego_wireless.messages import HubAttachedIO 19 | from lego_wireless.messages import HubProperties 20 | from lego_wireless.messages import message_classes 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | DEFAULT_NAME = "HUB NO.4" 25 | 26 | 27 | class Hub(gatt.Device): 28 | def __init__(self, *args, **kwargs): 29 | super().__init__(*args, **kwargs) 30 | self.ports = {} 31 | self.hub_characteristic = None 32 | self._battery_level = None 33 | self._connected = False 34 | 35 | @property 36 | def battery_level(self): 37 | return self._battery_level 38 | 39 | @property 40 | def connected(self): 41 | return self._connected 42 | 43 | @property 44 | def train_motor(self): 45 | for hub_io in self.ports.values(): 46 | if isinstance(hub_io, TrainMotor): 47 | return hub_io 48 | 49 | @property 50 | def led_light(self): 51 | for hub_io in self.ports.values(): 52 | if isinstance(hub_io, LEDLight): 53 | return hub_io 54 | 55 | @property 56 | def rgb_light(self) -> typing.Optional[RGBLight]: 57 | for hub_io in self.ports.values(): 58 | if isinstance(hub_io, RGBLight): 59 | return hub_io 60 | return None 61 | 62 | def connect_succeeded(self): 63 | super().connect_succeeded() 64 | self._connected = True 65 | self.manager.hubs.add(self) 66 | logger.info("[%s] Connected" % (self.mac_address)) 67 | 68 | def connect_failed(self, error): 69 | super().connect_failed(error) 70 | logger.warning("[%s] Connection failed: %s" % (self.mac_address, str(error))) 71 | 72 | def disconnect_succeeded(self): 73 | super().disconnect_succeeded() 74 | self._connected = False 75 | signals.hub_disconnected.send(self) 76 | 77 | def characteristic_enable_notification_succeeded(self, *args, **kwargs): 78 | super().characteristic_enable_notification_succeeded(*args, **kwargs) 79 | logger.info( 80 | "[%s] Characteristic enable notification_succeeded: %s %s" 81 | % (self.mac_address, str(args), str(kwargs)) 82 | ) 83 | 84 | def characteristic_value_updated(self, characteristic, value): 85 | super().characteristic_value_updated(characteristic, value) 86 | logger.debug("[%s] Message received: %r", self.mac_address, value) 87 | message = self.parse_message(value) 88 | logger.debug("[%s] Parsed message received: %r", self.mac_address, message) 89 | print(message) 90 | if isinstance(message, HubAttachedIO): 91 | if message.event in ( 92 | HubAttachedIOEvent.AttachedIO, 93 | HubAttachedIOEvent.AttachedVirtualIO, 94 | ): 95 | if message.io_type in HubIO.registry: 96 | self.ports[message.port] = HubIO.registry[message.io_type]( 97 | self, message.port 98 | ) 99 | logger.info("New IO on port %d: %s", message.port, message.io_type) 100 | signals.hub_io_connected.send(self, hub_io=self.ports[message.port]) 101 | self.hub_io_connected(self.ports[message.port]) 102 | else: 103 | logger.warning( 104 | "Unimplemented IOType on port %d: %s", 105 | message.port, 106 | message.io_type, 107 | ) 108 | else: 109 | if message.port in self.ports: 110 | logger.debug( 111 | "Removed IO on port %d: %s", 112 | message.port, 113 | self.ports[message.port].io_type, 114 | ) 115 | signals.hub_io_disconnected.send( 116 | self, hub_io=self.ports[message.port] 117 | ) 118 | self.hub_io_disconnected(self.ports[message.port]) 119 | del self.ports[message.port] 120 | elif ( 121 | isinstance(message, HubProperties) 122 | and message.property == HubProperty.BatteryVoltage 123 | and message.operation == HubPropertyOperation.Update 124 | ): 125 | self._battery_level = int(message.payload[0]) 126 | logger.debug( 127 | "[%s] Hub battery level: %d%%", self.mac_address, self._battery_level 128 | ) 129 | signals.hub_battery_level.send(self, battery_level=self._battery_level) 130 | else: 131 | logger.warning("Unexpected message: %s", message) 132 | 133 | def hub_io_connected(self, hub_io): 134 | pass 135 | 136 | def hub_io_disconnected(self, hub_io): 137 | pass 138 | 139 | def send_message(self, message): 140 | if hasattr(message, "to_bytes"): 141 | logger.info("Sending message: %r", message) 142 | message = message.to_bytes() 143 | 144 | assert isinstance(message, bytes) 145 | 146 | length = len(message) + 2 147 | message = bytes([length, 0x00]) + message 148 | logger.info("Sending serialized message: %r", message) 149 | self.hub_characteristic.write_value(message) 150 | 151 | def parse_message(self, message): 152 | length = message[0] 153 | if not len(message) == length: 154 | logger.warning("Unexpected message length: %r", message) 155 | return 156 | message_type = MessageType(message[2]) 157 | try: 158 | message_cls = message_classes[message_type] 159 | except KeyError: 160 | logger.warning("Unexpected message type: %s %r", message_type, message) 161 | else: 162 | return message_cls.from_bytes(message[3:]) 163 | 164 | @property 165 | def name(self) -> typing.Optional[str]: 166 | try: 167 | return self._name 168 | except AttributeError: 169 | name = self._properties.Get("org.bluez.Device1", "Name") 170 | self._name: typing.Optional[str] = name if name != DEFAULT_NAME else None 171 | return self._name 172 | 173 | @name.setter 174 | def name(self, value: typing.Optional[str]): 175 | if not value: 176 | self.send_message( 177 | HubProperties( 178 | property=HubProperty.AdvertisingName, 179 | operation=HubPropertyOperation.Reset, 180 | payload=b"", 181 | ) 182 | ) 183 | self._name = None 184 | elif len(value.encode()) > 14: 185 | raise ValueError("name cannot be longer than 14 characters") 186 | else: 187 | self.send_message( 188 | HubProperties( 189 | property=HubProperty.AdvertisingName, 190 | operation=HubPropertyOperation.Set, 191 | payload=value.encode(), 192 | ) 193 | ) 194 | self._name = value 195 | 196 | def services_resolved(self): 197 | logger.debug("Services resolved for %s", self.mac_address) 198 | super().services_resolved() 199 | 200 | for service in self.services: 201 | for characteristic in service.characteristics: 202 | if str(characteristic.uuid) == CHARACTERISTIC_UUID: 203 | 204 | self.hub_characteristic = characteristic 205 | self.hub_characteristic.enable_notifications() 206 | 207 | self.send_message( 208 | struct.pack( 209 | "BBB", 210 | MessageType.HubProperties, 211 | HubProperty.BatteryVoltage, 212 | HubPropertyOperation.EnableUpdates, 213 | ) 214 | ) 215 | self.send_message( 216 | struct.pack( 217 | "BBB", 218 | MessageType.HubProperties, 219 | HubProperty.Button, 220 | HubPropertyOperation.EnableUpdates, 221 | ) 222 | ) 223 | signals.hub_connected.send(self) 224 | return 225 | logger.debug("Device %s is not a LWP Hub", self.mac_address) 226 | 227 | def async_connect(self): 228 | threading.Thread(target=self.connect).run() 229 | 230 | def async_disconnect(self): 231 | threading.Thread(target=self.disconnect).run() 232 | -------------------------------------------------------------------------------- /lego_wireless/hub_io.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import struct 4 | import typing 5 | 6 | from .enums import ColorNo 7 | from .enums import IOType 8 | from .enums import MessageType 9 | 10 | 11 | class HubIOMetaclass(type): 12 | def __new__(mcs, name, bases, attrs): 13 | cls = type.__new__(mcs, name, bases, attrs) 14 | if getattr(cls, "io_type", None): 15 | cls.registry[cls.io_type] = cls 16 | return cls 17 | 18 | 19 | class HubIO(metaclass=HubIOMetaclass): 20 | io_type: IOType 21 | registry: typing.Dict[IOType, HubIO] = {} 22 | 23 | def __init__(self, hub, port): 24 | self.hub = hub 25 | self.port = port 26 | 27 | 28 | class TrainMotor(HubIO): 29 | io_type = IOType.TrainMotor 30 | 31 | def set_speed(self, value): 32 | self.hub.send_message( 33 | struct.pack( 34 | "BBBBBBBB", 35 | MessageType.PortOutput, 36 | self.port, 37 | 0x00, 38 | 0x60, 39 | 0x00, 40 | value, 41 | 0x00, 42 | 0x00, 43 | ) 44 | ) 45 | 46 | 47 | class LEDLight(HubIO): 48 | io_type = IOType.LEDLight 49 | 50 | def set_brightness(self, value): 51 | self.hub.send_message( 52 | struct.pack( 53 | "BBBBBBBB", 54 | MessageType.PortOutput, 55 | self.port, 56 | 0x00, 57 | 0x60, 58 | 0x00, 59 | value, 60 | 0x00, 61 | 0x00, 62 | ) 63 | ) 64 | 65 | 66 | class Voltage(HubIO): 67 | io_type = IOType.Voltage 68 | 69 | 70 | class RGBLight(HubIO): 71 | io_type = IOType.RGBLight 72 | 73 | def set_rgb_color_no(self, color_no: ColorNo): 74 | self.hub.send_message( 75 | struct.pack( 76 | "BBBBBB", MessageType.PortOutput, self.port, 0x01, 0x51, 0x00, color_no 77 | ) 78 | ) 79 | 80 | 81 | class Current(HubIO): 82 | io_type = IOType.Current 83 | -------------------------------------------------------------------------------- /lego_wireless/manager.py: -------------------------------------------------------------------------------- 1 | import gatt 2 | 3 | from . import signals 4 | from .constants import SERVICE_UUID 5 | from .hub import Hub 6 | 7 | 8 | class HubManager(gatt.DeviceManager): 9 | def __init__(self, *args, **kwargs): 10 | super().__init__(*args, **kwargs) 11 | self.hubs = set() 12 | 13 | def make_device(self, mac_address): 14 | return Hub(mac_address=mac_address, manager=self) 15 | 16 | def device_discovered(self, device: gatt.Device): 17 | super().device_discovered(device) 18 | if SERVICE_UUID in map( 19 | str, device._properties.Get("org.bluez.Device1", "UUIDs") 20 | ): 21 | signals.hub_discovered.send(self, hub=device) 22 | 23 | def start_discovery(self): 24 | return super().start_discovery([SERVICE_UUID]) 25 | 26 | def stop(self): 27 | for hub in self.hubs: 28 | hub.disconnect() 29 | super().stop() 30 | -------------------------------------------------------------------------------- /lego_wireless/messages.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import struct 3 | 4 | from .enums import ErrorCode 5 | from .enums import HubAttachedIOEvent 6 | from .enums import HubProperty 7 | from .enums import HubPropertyOperation 8 | from .enums import IOType 9 | from .enums import MessageType 10 | 11 | 12 | class HubAttachedIO( 13 | collections.namedtuple("HubAttachedIO", ("port", "event", "io_type")) 14 | ): 15 | @classmethod 16 | def from_bytes(cls, value): 17 | port, event = struct.unpack("BB", value[:2]) 18 | event = HubAttachedIOEvent(event) 19 | if event in ( 20 | HubAttachedIOEvent.AttachedIO, 21 | HubAttachedIOEvent.AttachedVirtualIO, 22 | ): 23 | io_type = IOType(struct.unpack(""] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/alexsdutton/python-lego-wireless-protocol" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.9" 12 | gatt = "*" 13 | blinker = "*" 14 | 15 | [tool.poetry.dev-dependencies] 16 | 17 | [build-system] 18 | requires = ["poetry-core>=1.0.0"] 19 | build-backend = "poetry.core.masonry.api" 20 | --------------------------------------------------------------------------------