lumalou 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lumalou-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: lumalou
3
+ Version: 0.1.0
4
+ Summary: Local BLE control for the Fisher-Price Lumalou — reverse-engineered MPID protocol, pure Python
5
+ Author: Emanuele Strazzullo
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/stramanu/lumalou
8
+ Project-URL: Issues, https://github.com/stramanu/lumalou/issues
9
+ Keywords: fisher-price,lumalou,ble,bluetooth,bleak,reverse-engineering,gld09
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Home Automation
15
+ Classifier: Framework :: AsyncIO
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: bleak>=0.22
19
+ Requires-Dist: cryptography>=41
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == "dev"
22
+
23
+ # lumalou (Python)
24
+
25
+ Local BLE control for the Fisher-Price Lumalou. Pure Python (async, [bleak](https://github.com/hbldh/bleak)).
26
+
27
+ ```bash
28
+ pip install lumalou
29
+ ```
30
+
31
+ ## Library
32
+
33
+ ```python
34
+ import asyncio
35
+ from lumalou import LumalouClient, Color
36
+
37
+ async def main():
38
+ devices = await LumalouClient.scan()
39
+ async with LumalouClient(devices[0]["address"]) as luma:
40
+ await luma.light_color(Color.RAINBOW)
41
+ await luma.play(0) # guided sleep playlist
42
+ await luma.volume(5)
43
+ print(await luma.request_state())
44
+
45
+ asyncio.run(main())
46
+ ```
47
+
48
+ ## CLI
49
+
50
+ ```bash
51
+ lumalou scan
52
+ lumalou light --color blue --brightness 7
53
+ lumalou sound --source ocean --volume 5
54
+ lumalou soother # --off to stop
55
+ lumalou time # sync clock
56
+ lumalou state # read full state
57
+ lumalou send 3c05 # raw app_data (opcode + args)
58
+ ```
59
+
60
+ Add `-a <address>` to target a device; otherwise it auto-scans.
61
+
62
+ Requires Python 3.10+, a Bluetooth LE adapter, and radio range of the device.
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ pip install -e ".[dev]"
68
+ pytest # runs the shared golden vectors (spec/vectors.json)
69
+ ```
70
+
71
+ MIT.
@@ -0,0 +1,49 @@
1
+ # lumalou (Python)
2
+
3
+ Local BLE control for the Fisher-Price Lumalou. Pure Python (async, [bleak](https://github.com/hbldh/bleak)).
4
+
5
+ ```bash
6
+ pip install lumalou
7
+ ```
8
+
9
+ ## Library
10
+
11
+ ```python
12
+ import asyncio
13
+ from lumalou import LumalouClient, Color
14
+
15
+ async def main():
16
+ devices = await LumalouClient.scan()
17
+ async with LumalouClient(devices[0]["address"]) as luma:
18
+ await luma.light_color(Color.RAINBOW)
19
+ await luma.play(0) # guided sleep playlist
20
+ await luma.volume(5)
21
+ print(await luma.request_state())
22
+
23
+ asyncio.run(main())
24
+ ```
25
+
26
+ ## CLI
27
+
28
+ ```bash
29
+ lumalou scan
30
+ lumalou light --color blue --brightness 7
31
+ lumalou sound --source ocean --volume 5
32
+ lumalou soother # --off to stop
33
+ lumalou time # sync clock
34
+ lumalou state # read full state
35
+ lumalou send 3c05 # raw app_data (opcode + args)
36
+ ```
37
+
38
+ Add `-a <address>` to target a device; otherwise it auto-scans.
39
+
40
+ Requires Python 3.10+, a Bluetooth LE adapter, and radio range of the device.
41
+
42
+ ## Development
43
+
44
+ ```bash
45
+ pip install -e ".[dev]"
46
+ pytest # runs the shared golden vectors (spec/vectors.json)
47
+ ```
48
+
49
+ MIT.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lumalou"
7
+ version = "0.1.0"
8
+ description = "Local BLE control for the Fisher-Price Lumalou — reverse-engineered MPID protocol, pure Python"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Emanuele Strazzullo" }]
13
+ keywords = ["fisher-price", "lumalou", "ble", "bluetooth", "bleak", "reverse-engineering", "gld09"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Home Automation",
20
+ "Framework :: AsyncIO",
21
+ ]
22
+ dependencies = ["bleak>=0.22", "cryptography>=41"]
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/stramanu/lumalou"
29
+ Issues = "https://github.com/stramanu/lumalou/issues"
30
+
31
+ [project.scripts]
32
+ lumalou = "lumalou.cli:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+ include = ["lumalou*"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ """
2
+ lumalou — local BLE control for the Fisher-Price Lumalou (gld09).
3
+
4
+ Reverse-engineered MPID protocol: ECDH P-256 handshake + AES-128-CTR, pure Python.
5
+
6
+ import asyncio
7
+ from lumalou import LumalouClient, Color
8
+
9
+ async def main():
10
+ devices = await LumalouClient.scan()
11
+ async with LumalouClient(devices[0]["address"]) as luma:
12
+ await luma.light_color(Color.RAINBOW)
13
+ print(await luma.request_state())
14
+
15
+ asyncio.run(main())
16
+ """
17
+ from .client import LumalouClient
18
+ from ._generated import (Color, Audio, Song, LightDuration, PlaylistDuration,
19
+ NapDuration, Alarm, RoutineControl, ClockFormat,
20
+ OperationMode, Stage)
21
+ from .protocol import build_tx_frame, decrypt_rx_frame, encode_command, crc8
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "LumalouClient",
27
+ "Color", "Audio", "Song", "LightDuration", "PlaylistDuration", "NapDuration",
28
+ "Alarm", "RoutineControl", "ClockFormat", "OperationMode", "Stage",
29
+ "build_tx_frame", "decrypt_rx_frame", "encode_command", "crc8",
30
+ ]
@@ -0,0 +1,208 @@
1
+ """AUTO-GENERATED from spec/protocol.json by tools/codegen.py. Do not edit."""
2
+ from enum import IntEnum
3
+
4
+ GATT = {
5
+ "service": "4cea0001-c678-4202-b5d3-712dbb5e5b14",
6
+ "characteristics": {
7
+ "tx": "4cea0002-c678-4202-b5d3-712dbb5e5b14",
8
+ "rx": "4cea0003-c678-4202-b5d3-712dbb5e5b14",
9
+ "factory": "4cea0004-c678-4202-b5d3-712dbb5e5b14",
10
+ "session": "4cea0005-c678-4202-b5d3-712dbb5e5b14"
11
+ },
12
+ "blacklist": {
13
+ "dfuService": "00001530-1212-efde-1523-785feabcd123",
14
+ "note": "Nordic DFU / firmware update service. Never write to it."
15
+ }
16
+ }
17
+
18
+ COMMANDS = {
19
+ "SET_GLOBAL_STATE": 1,
20
+ "SET_GLOBAL_ON": 3,
21
+ "SET_CURRENT_DATE": 48,
22
+ "REQUEST_CURRENT_DATE": 49,
23
+ "SEND_PAIRING_COMPLETE": 52,
24
+ "REQUEST_TOYIC_FW_VERSION": 53,
25
+ "SET_VOLUME": 55,
26
+ "TURN_OFF_AUDIO": 56,
27
+ "REQUEST_VOLUME": 57,
28
+ "SET_LED_BRIGHTNESS": 58,
29
+ "REQUEST_LED_BRIGHTNESS": 59,
30
+ "SET_LIGHT_COLOR": 60,
31
+ "REQUEST_LIGHT_COLOR": 61,
32
+ "TURN_OFF_CLOUD_BACKLIGHT": 62,
33
+ "PLAY_AUDIO": 63,
34
+ "SET_MUSIC_PLAYLIST": 64,
35
+ "REQUEST_MUSIC_PLAYLIST": 65,
36
+ "SET_PLAYLIST_DURATION": 66,
37
+ "REQUEST_PLAYLIST_DURATION": 67,
38
+ "SET_R2R_STATUS": 68,
39
+ "REQUEST_R2R_STATUS": 69,
40
+ "SET_R2R_TIMES": 70,
41
+ "REQUEST_R2R_TIMES": 71,
42
+ "SET_SLEEPY_TIMES": 72,
43
+ "REQUEST_SLEEPY_TIMES": 73,
44
+ "SET_R2R_ALARMS": 74,
45
+ "REQUEST_R2R_ALARM_STATUS": 75,
46
+ "REQUEST_R2R_ALARMS": 76,
47
+ "START_NAP_TIME": 77,
48
+ "REQUEST_CURRENT_NAP_TIME_STATUS": 78,
49
+ "SET_NAP_TIME_ALARM": 79,
50
+ "REQUEST_NAP_TIME_ALARM_STATUS": 80,
51
+ "REQUEST_NAP_TIME_ALARM": 81,
52
+ "SET_TIME_PRESCALER": 82,
53
+ "REQUEST_GLOBAL_STATE": 83,
54
+ "REQUEST_SONG_PLAYING": 85,
55
+ "SET_ROUTINE_MODE_STATUS": 88,
56
+ "REQUEST_ROUTINE_MODE_STATUS": 89,
57
+ "SET_ROUTINE_TASK_STATUS": 104,
58
+ "SET_ROUTINE_MUSIC_STATUS": 105,
59
+ "REQUEST_ROUTINE_MUSIC_STATUS": 106,
60
+ "ROUTINE_CONTROL_COMMAND": 107,
61
+ "SET_SOOTHER_MODE_LIGHT_DURATION": 108,
62
+ "REQUEST_SOOTHER_MODE_LIGHT_DURATION": 109,
63
+ "REQUEST_OPERATION_MODE": 114,
64
+ "REQUEST_TIME_PRESCALER": 115,
65
+ "REQUEST_ACTIVITY_STATE": 116,
66
+ "REQUEST_CURRENT_STAGE": 117,
67
+ "REQUEST_TRANSMISSION_MODE": 118,
68
+ "SET_ROUTINE_MODE_VOLUME": 119,
69
+ "REQUEST_ROUTINE_MODE_VOLUME": 120,
70
+ "SET_CLOCK_SETTINGS": 121,
71
+ "REQUEST_CLOCK_SETTINGS": 122,
72
+ "START_ROUTINE_MODE": 123
73
+ }
74
+ DAY_ROUTINE = {
75
+ "SET": {
76
+ "sunday": 90,
77
+ "monday": 92,
78
+ "tuesday": 94,
79
+ "wednesday": 96,
80
+ "thursday": 98,
81
+ "friday": 100,
82
+ "saturday": 102
83
+ },
84
+ "REQUEST": {
85
+ "sunday": 91,
86
+ "monday": 93,
87
+ "tuesday": 95,
88
+ "wednesday": 97,
89
+ "thursday": 99,
90
+ "friday": 101,
91
+ "saturday": 103
92
+ }
93
+ }
94
+ RESPONSES = {2: "GLOBAL_STATE", 17: "OTA_COMPLETE", 18: "TOYIC_FW_VERSION", 19: "CURRENT_DATE", 20: "CURRENT_SONG", 21: "CURRENT_VOLUME", 22: "LIGHT_STATUS", 23: "LIGHT_BRIGHTNESS", 24: "LIGHT_COLOR", 25: "CURRENT_PLAYLIST", 26: "AUDIO_TIMER_DURATION", 27: "TIMER_ABOUT_TO_EXPIRE", 28: "NAP_TIME_STATUS", 29: "TRANSMISSION_MODE", 30: "OPERATION_MODE", 31: "ACTIVITY_STATE", 32: "CURRENT_STAGE", 33: "READY_TO_RISE_STATUS", 34: "READY_TO_RISE_TIMES", 35: "SLEEPY_TIME_TIMES", 36: "NAP_TIME_ALARM_STATUS", 37: "NAP_TIME_ALARM_TIME", 38: "READY_TO_RISE_ALARM_STATUS", 39: "READY_TO_RISE_ALARM_TIMES", 40: "TIME_PRESCALER", 43: "SUNDAY_ROUTINE", 44: "MONDAY_ROUTINE", 45: "TUESDAY_ROUTINE", 46: "WEDNESDAY_ROUTINE", 47: "THURSDAY_ROUTINE", 144: "FRIDAY_ROUTINE", 145: "SATURDAY_ROUTINE", 146: "ROUTINE_MODE_STATUS", 147: "ROUTINE_MUSIC_STATUS", 148: "ROUTINE_TASK_STATUS", 149: "LIGHT_DURATION", 152: "ROUTINE_VOLUME_LEVEL", 153: "CLOCK_SETTINGS"}
95
+
96
+ class Color(IntEnum):
97
+ WARM = 0
98
+ RED = 1
99
+ YELLOW = 2
100
+ ORANGE = 3
101
+ GREEN = 4
102
+ BLUE = 5
103
+ PURPLE = 6
104
+ NIGHT_LIGHT = 7
105
+ COOL = 8
106
+ RAINBOW = 9
107
+
108
+ class Audio(IntEnum):
109
+ SLEEP_PLAYLIST = 0
110
+ CUSTOM_PLAYLIST = 1
111
+ PINK_NOISE = 2
112
+ OCEAN = 3
113
+ RAIN = 4
114
+ BROWN_NOISE = 5
115
+ NATURE = 6
116
+ HIGHWAY = 7
117
+
118
+ class Song(IntEnum):
119
+ NO_SONG = 0
120
+ SLEEP_BABY_SLEEP = 1
121
+ HOUR_GLASS = 2
122
+ FRERE_JACQUES = 3
123
+ HOW_LOVELY_THE_EVENING = 4
124
+ TARREGA_LAGRIMA = 5
125
+ WHATS_THE_MATTER_DEAR = 6
126
+ SUO_GAN = 7
127
+ WATER_COLOR_DREAMS = 8
128
+ DANCE_OF_THE_JELLYFISH = 9
129
+ INSIDE_THE_BUBBLE = 10
130
+ PAPER_KITES = 11
131
+ CRICKETS_IN_SPACE = 12
132
+ PINK_NOISE = 13
133
+ OCEAN = 14
134
+ RAIN = 15
135
+ BROWN_NOISE = 16
136
+ NATURE = 17
137
+ HIGHWAY = 18
138
+
139
+ class LightDuration(IntEnum):
140
+ MIN_15 = 0
141
+ MIN_30 = 1
142
+ MIN_60 = 2
143
+ MIN_90 = 3
144
+ CONTINUOUS = 4
145
+ MIN_1 = 5
146
+
147
+ class PlaylistDuration(IntEnum):
148
+ MIN_15 = 0
149
+ MIN_30 = 1
150
+ MIN_60 = 2
151
+ MIN_90 = 3
152
+ MIN_120 = 4
153
+ CONTINUOUS = 5
154
+ MIN_1 = 6
155
+
156
+ class NapDuration(IntEnum):
157
+ INACTIVE = 0
158
+ MIN_15 = 1
159
+ MIN_30 = 2
160
+ MIN_45 = 3
161
+ MIN_60 = 4
162
+ MIN_75 = 5
163
+ MIN_90 = 6
164
+ MIN_105 = 7
165
+ MIN_120 = 8
166
+ MIN_150 = 9
167
+ MIN_180 = 10
168
+ MIN_1 = 11
169
+
170
+ class Alarm(IntEnum):
171
+ ACTIVE = 0
172
+ AFTER_15 = 1
173
+ AFTER_30 = 2
174
+ AFTER_45 = 3
175
+ AFTER_60 = 4
176
+ AFTER_75 = 5
177
+ AFTER_90 = 6
178
+ AFTER_105 = 7
179
+ AFTER_120 = 8
180
+ INACTIVE = 9
181
+ AFTER_1 = 10
182
+
183
+ class RoutineControl(IntEnum):
184
+ COMPLETE_TASK = 0
185
+ PREV_TASK = 1
186
+ RESTART_SEQUENCE = 2
187
+ COMPLETE_SEQUENCE = 3
188
+ CANCEL = 4
189
+
190
+ class OperationMode(IntEnum):
191
+ SOOTHER = 0
192
+ DAYTIME_AWAKE = 2
193
+ SLEEPY_TIME = 3
194
+ PAIRING = 4
195
+ FIRMWARE_UPDATE = 5
196
+ NAPTIME = 6
197
+ ROUTINE = 7
198
+
199
+ class Stage(IntEnum):
200
+ NONE = 0
201
+ READY = 1
202
+ SETTLE = 2
203
+ SLEEP = 3
204
+
205
+ class ClockFormat(IntEnum):
206
+ H12 = 0
207
+ H24 = 1
208
+
@@ -0,0 +1,111 @@
1
+ """Command-line interface. `lumalou --help`"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import json
7
+
8
+ from . import commands as C
9
+ from ._generated import Color, Audio
10
+ from .client import LumalouClient
11
+
12
+ _COLORS = {c.name.lower(): int(c) for c in Color}
13
+ _AUDIO = {"playlist": 0, "custom": 1, "pink": 2, "ocean": 3, "rain": 4,
14
+ "brown": 5, "nature": 6, "highway": 7}
15
+
16
+
17
+ def _color_id(v):
18
+ return _COLORS[v.lower()] if v.lower() in _COLORS else int(v)
19
+
20
+
21
+ def _audio_id(v):
22
+ return _AUDIO[v.lower()] if v.lower() in _AUDIO else int(v)
23
+
24
+
25
+ async def _resolve_address(addr):
26
+ if addr:
27
+ return addr
28
+ devices = await LumalouClient.scan(timeout=8.0)
29
+ if not devices:
30
+ raise SystemExit("No device found. Turn the Lumalou on and try again.")
31
+ print(f"[auto] using {devices[0]['name']} ({devices[0]['address']}, {devices[0]['rssi']} dBm)")
32
+ return devices[0]["address"]
33
+
34
+
35
+ async def _run(args):
36
+ if args.cmd == "scan":
37
+ for d in await LumalouClient.scan(timeout=args.timeout):
38
+ print(f" {d['rssi']:>4} dBm {d['name'] or '(no name)':<20} {d['address']} mfg={d['manufacturer']}")
39
+ return
40
+
41
+ address = await _resolve_address(args.address)
42
+ async with LumalouClient(address) as luma:
43
+ if args.cmd == "state":
44
+ print(json.dumps(await luma.request_state(), indent=2))
45
+ elif args.cmd == "light":
46
+ if args.color is not None:
47
+ await luma.light_color(_color_id(args.color))
48
+ if args.brightness is not None:
49
+ await luma.brightness(args.brightness)
50
+ if args.off:
51
+ await luma.light_off()
52
+ elif args.cmd == "sound":
53
+ if args.source is not None:
54
+ await luma.play(_audio_id(args.source))
55
+ if args.volume is not None:
56
+ await luma.volume(args.volume)
57
+ if args.off:
58
+ await luma.audio_off()
59
+ elif args.cmd == "soother":
60
+ await luma.soother(not args.off)
61
+ elif args.cmd == "nap":
62
+ await luma.nap(args.minutes)
63
+ elif args.cmd == "time":
64
+ await luma.sync_time()
65
+ print("clock synced")
66
+ elif args.cmd == "send":
67
+ await luma.send(bytes.fromhex(args.hex))
68
+
69
+
70
+ def build_parser():
71
+ p = argparse.ArgumentParser(prog="lumalou", description="BLE control for the Fisher-Price Lumalou.")
72
+ p.add_argument("-a", "--address", help="device address/UUID (default: auto-scan)")
73
+ sub = p.add_subparsers(dest="cmd", required=True)
74
+
75
+ s = sub.add_parser("scan", help="find Lumalou devices")
76
+ s.add_argument("--timeout", type=float, default=8.0)
77
+
78
+ sub.add_parser("state", help="read the full state")
79
+
80
+ li = sub.add_parser("light", help="control the light")
81
+ li.add_argument("-c", "--color", help="warm|red|yellow|orange|green|blue|purple|night_light|cool|rainbow or 0-9")
82
+ li.add_argument("-b", "--brightness", type=int, help="0-9")
83
+ li.add_argument("--off", action="store_true", help="turn the light off")
84
+
85
+ au = sub.add_parser("sound", help="control audio")
86
+ au.add_argument("-s", "--source", help="playlist|custom|pink|ocean|rain|brown|nature|highway or 0-7")
87
+ au.add_argument("-v", "--volume", type=int, help="0-9")
88
+ au.add_argument("--off", action="store_true", help="stop audio")
89
+
90
+ so = sub.add_parser("soother", help="start/stop the soother")
91
+ so.add_argument("--off", action="store_true")
92
+
93
+ n = sub.add_parser("nap", help="start a nap")
94
+ n.add_argument("minutes", type=int, help="duration id (1=15, 2=30, ... 10=180)")
95
+
96
+ sub.add_parser("time", help="sync the device clock")
97
+
98
+ se = sub.add_parser("send", help="send raw app_data in hex")
99
+ se.add_argument("hex", help="e.g. 3c05 = blue light")
100
+ return p
101
+
102
+
103
+ def main(argv=None):
104
+ try:
105
+ asyncio.run(_run(build_parser().parse_args(argv)))
106
+ except KeyboardInterrupt:
107
+ pass
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
@@ -0,0 +1,157 @@
1
+ """Async BLE client (bleak): scan, handshake, send commands, read state."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import datetime
6
+ from typing import Callable, Optional
7
+
8
+ from bleak import BleakClient, BleakScanner
9
+
10
+ from . import commands as C
11
+ from . import crypto
12
+ from . import protocol as P
13
+ from . import responses as R
14
+ from ._generated import GATT
15
+
16
+ SERVICE = GATT["service"]
17
+ TX = GATT["characteristics"]["tx"]
18
+ RX = GATT["characteristics"]["rx"]
19
+ FACTORY = GATT["characteristics"]["factory"]
20
+ SESSION = GATT["characteristics"]["session"]
21
+
22
+ WRITE_SPACING = 0.15 # seconds between writes (the device is sensitive to bursts)
23
+
24
+
25
+ class LumalouClient:
26
+ """Async client. Use as an async context manager or via connect()/disconnect()."""
27
+
28
+ def __init__(self, address: str, *, on_state: Optional[Callable[[dict], None]] = None):
29
+ self.address = address
30
+ self.connected = False
31
+ self._on_state = on_state
32
+ self._client: Optional[BleakClient] = None
33
+ self._key = self._nonce = self._salt = None
34
+ self._seq = 1
35
+ self._lock = asyncio.Lock()
36
+ self._state: Optional[dict] = None
37
+ self._state_waiters: list[asyncio.Future] = []
38
+
39
+ @staticmethod
40
+ async def scan(timeout: float = 8.0) -> list[dict]:
41
+ """Scan for BLE devices. The Lumalou advertises its AP number as its name."""
42
+ found = await BleakScanner.discover(timeout=timeout, return_adv=True)
43
+ out = [{
44
+ "address": addr,
45
+ "name": adv.local_name or dev.name,
46
+ "rssi": adv.rssi,
47
+ "manufacturer": {str(k): v.hex() for k, v in adv.manufacturer_data.items()},
48
+ } for addr, (dev, adv) in found.items()]
49
+ out.sort(key=lambda r: -(r["rssi"] or -999))
50
+ return out
51
+
52
+ async def connect(self):
53
+ self._client = BleakClient(self.address)
54
+ await self._client.connect()
55
+ await self._client.start_notify(RX, self._on_rx)
56
+ token = bytes(await self._client.read_gatt_char(FACTORY))
57
+ device_pub = crypto.token_device_pubkey(token)
58
+ self._salt = crypto.token_device_salt(token)
59
+ priv, pub = crypto.generate_keypair()
60
+ self._nonce = crypto.new_nonce()
61
+ self._key = crypto.derive_session_key(priv, device_pub)[:16]
62
+ await self._client.write_gatt_char(SESSION, pub + self._nonce, response=True) # 37B
63
+ await asyncio.sleep(0.4)
64
+ await self._write(C.ENABLE_RX) # let the toy-IC stream responses
65
+ await asyncio.sleep(0.3)
66
+ self.connected = True
67
+ return self
68
+
69
+ async def disconnect(self):
70
+ self.connected = False
71
+ if self._client:
72
+ try:
73
+ await self._client.disconnect()
74
+ except Exception:
75
+ pass
76
+
77
+ async def __aenter__(self):
78
+ return await self.connect()
79
+
80
+ async def __aexit__(self, *exc):
81
+ await self.disconnect()
82
+
83
+ async def _write(self, plaintext: bytes):
84
+ async with self._lock:
85
+ frame = P.build_tx_frame(plaintext, self._seq, self._key, self._nonce, self._salt)
86
+ self._seq += 1
87
+ await self._client.write_gatt_char(TX, frame, response=False)
88
+ await asyncio.sleep(WRITE_SPACING)
89
+
90
+ async def send(self, app_data: bytes):
91
+ """Send a raw application command ([opcode] + args)."""
92
+ await self._write(P.encode_command(app_data))
93
+
94
+ def _on_rx(self, _sender, data: bytearray):
95
+ res = P.decrypt_rx_frame(bytes(data), self._key, self._nonce, self._salt)
96
+ if not res or not res["crc_ok"]:
97
+ return
98
+ r = P.parse_response_frame(res["plaintext"])
99
+ if r.get("ok") and r.get("opcode") == 0x02:
100
+ st = R.parse_global_state(r["args"])
101
+ self._state = st
102
+ for fut in self._state_waiters:
103
+ if not fut.done():
104
+ fut.set_result(st)
105
+ self._state_waiters.clear()
106
+ if self._on_state:
107
+ self._on_state(st)
108
+
109
+ async def request_state(self, timeout: float = 3.0) -> Optional[dict]:
110
+ """Query the full GLOBAL_STATE and return it decoded."""
111
+ fut = asyncio.get_event_loop().create_future()
112
+ self._state_waiters.append(fut)
113
+ await self.send(C.request("global_state"))
114
+ try:
115
+ return await asyncio.wait_for(fut, timeout)
116
+ except asyncio.TimeoutError:
117
+ return self._state
118
+
119
+ @property
120
+ def state(self) -> Optional[dict]:
121
+ return self._state
122
+
123
+ # ---- high-level API ----
124
+ async def light_color(self, color: int):
125
+ await self.send(C.set_light_color(int(color)))
126
+
127
+ async def brightness(self, level: int):
128
+ await self.send(C.set_led_brightness(level))
129
+
130
+ async def light_off(self):
131
+ await self.send(C.turn_off_backlight())
132
+
133
+ async def light_duration(self, duration: int):
134
+ await self.send(C.set_light_duration(int(duration)))
135
+
136
+ async def volume(self, level: int):
137
+ await self.send(C.set_volume(level))
138
+
139
+ async def play(self, source: int):
140
+ await self.send(C.play_audio(int(source)))
141
+
142
+ async def audio_off(self):
143
+ await self.send(C.turn_off_audio())
144
+
145
+ async def playlist(self, song_ids):
146
+ await self.send(C.set_music_playlist(song_ids))
147
+
148
+ async def nap(self, duration: int):
149
+ await self.send(C.start_nap(int(duration)))
150
+
151
+ async def soother(self, on: bool = True):
152
+ await self.send(C.set_global_on(on))
153
+
154
+ async def sync_time(self, when: Optional[datetime.datetime] = None):
155
+ d = when or datetime.datetime.now()
156
+ weekday = (d.weekday() + 1) % 7 # Python Mon=0..Sun=6 -> device Sun=0..Sat=6
157
+ await self.send(C.set_current_date(d.hour, d.minute, d.second, weekday))
@@ -0,0 +1,134 @@
1
+ """Command builders. Each returns the app-level payload: [opcode] + args."""
2
+ from __future__ import annotations
3
+
4
+ from ._generated import COMMANDS, DAY_ROUTINE, Color, Audio, Song, LightDuration, \
5
+ PlaylistDuration, NapDuration, Alarm, RoutineControl, ClockFormat
6
+
7
+ NO_MODIFY = 0x0F # "leave unchanged" sentinel for SET_GLOBAL_STATE fields
8
+
9
+ _u8 = lambda *a: bytes(a)
10
+
11
+
12
+ def bcd(n) -> int:
13
+ return 0xFF if n is None else (((n // 10) << 4) | (n % 10)) & 0xFF
14
+
15
+
16
+ def reduce_low_nibbles(values) -> bytes:
17
+ v = list(values)
18
+ if len(v) % 2:
19
+ v = [0] + v
20
+ return bytes(((v[i] << 4) | (v[i + 1] & 0x0F)) & 0xFF for i in range(0, len(v), 2))
21
+
22
+
23
+ # ---- light ----
24
+ def set_light_color(color) -> bytes:
25
+ return _u8(COMMANDS["SET_LIGHT_COLOR"], int(color))
26
+
27
+
28
+ def set_led_brightness(level: int) -> bytes:
29
+ return _u8(COMMANDS["SET_LED_BRIGHTNESS"], level & 0xFF)
30
+
31
+
32
+ def set_light_duration(duration) -> bytes:
33
+ return _u8(COMMANDS["SET_SOOTHER_MODE_LIGHT_DURATION"], int(duration))
34
+
35
+
36
+ def turn_off_backlight() -> bytes:
37
+ return _u8(COMMANDS["TURN_OFF_CLOUD_BACKLIGHT"])
38
+
39
+
40
+ # ---- audio ----
41
+ def play_audio(source) -> bytes:
42
+ return _u8(COMMANDS["PLAY_AUDIO"], int(source))
43
+
44
+
45
+ def turn_off_audio() -> bytes:
46
+ return _u8(COMMANDS["TURN_OFF_AUDIO"])
47
+
48
+
49
+ def set_playlist_duration(duration) -> bytes:
50
+ return _u8(COMMANDS["SET_PLAYLIST_DURATION"], int(duration))
51
+
52
+
53
+ def set_music_playlist(song_ids) -> bytes:
54
+ ids = [int(s) for s in song_ids if int(s) != 0][:12]
55
+ ids += [0] * (12 - len(ids))
56
+ return bytes([COMMANDS["SET_MUSIC_PLAYLIST"], *ids])
57
+
58
+
59
+ # ---- volume ----
60
+ def set_volume(level: int) -> bytes:
61
+ return _u8(COMMANDS["SET_VOLUME"], level & 0xFF)
62
+
63
+
64
+ def set_routine_volume(level: int) -> bytes:
65
+ return _u8(COMMANDS["SET_ROUTINE_MODE_VOLUME"], level & 0xFF)
66
+
67
+
68
+ # ---- system ----
69
+ def set_global_on(on: bool) -> bytes:
70
+ return _u8(COMMANDS["SET_GLOBAL_ON"], 1 if on else 0)
71
+
72
+
73
+ def set_global_state(*, lights_on=None, brightness=None, music_on=None, volume=None,
74
+ r2r=None, r2r_alarm=None, nap_alarm=None, routine=None) -> bytes:
75
+ f = lambda x: NO_MODIFY if x is None else (int(x) & 0x0F)
76
+ return bytes([COMMANDS["SET_GLOBAL_STATE"], *reduce_low_nibbles(
77
+ [f(lights_on), f(brightness), f(music_on), f(volume),
78
+ f(r2r), f(r2r_alarm), f(nap_alarm), f(routine)])])
79
+
80
+
81
+ def set_current_date(hour, minute, second, weekday) -> bytes:
82
+ return _u8(COMMANDS["SET_CURRENT_DATE"], bcd(hour), bcd(minute), bcd(second), bcd(weekday))
83
+
84
+
85
+ def set_clock_settings(display_on, brightness, fmt) -> bytes:
86
+ return bytes([COMMANDS["SET_CLOCK_SETTINGS"],
87
+ *reduce_low_nibbles([0, int(bool(display_on)), brightness & 0x0F, int(fmt)])])
88
+
89
+
90
+ # ---- timers ----
91
+ def set_r2r_status(on: bool) -> bytes:
92
+ return _u8(COMMANDS["SET_R2R_STATUS"], 1 if on else 0)
93
+
94
+
95
+ def start_nap(duration) -> bytes:
96
+ return _u8(COMMANDS["START_NAP_TIME"], int(duration))
97
+
98
+
99
+ def set_nap_alarm(alarm) -> bytes:
100
+ return _u8(COMMANDS["SET_NAP_TIME_ALARM"], int(alarm))
101
+
102
+
103
+ # ---- routine ----
104
+ def set_routine_status(on: bool) -> bytes:
105
+ return _u8(COMMANDS["SET_ROUTINE_MODE_STATUS"], 1 if on else 0)
106
+
107
+
108
+ def start_routine_mode() -> bytes:
109
+ return _u8(COMMANDS["START_ROUTINE_MODE"])
110
+
111
+
112
+ def routine_control(ctrl) -> bytes:
113
+ return _u8(COMMANDS["ROUTINE_CONTROL_COMMAND"], int(ctrl))
114
+
115
+
116
+ # ---- read-only queries ----
117
+ _REQUESTS = {
118
+ "global_state": "REQUEST_GLOBAL_STATE", "current_date": "REQUEST_CURRENT_DATE",
119
+ "toyic_fw_version": "REQUEST_TOYIC_FW_VERSION", "led_brightness": "REQUEST_LED_BRIGHTNESS",
120
+ "light_color": "REQUEST_LIGHT_COLOR", "light_duration": "REQUEST_SOOTHER_MODE_LIGHT_DURATION",
121
+ "volume": "REQUEST_VOLUME", "routine_volume": "REQUEST_ROUTINE_MODE_VOLUME",
122
+ "song_playing": "REQUEST_SONG_PLAYING", "music_playlist": "REQUEST_MUSIC_PLAYLIST",
123
+ "playlist_duration": "REQUEST_PLAYLIST_DURATION", "operation_mode": "REQUEST_OPERATION_MODE",
124
+ "activity_state": "REQUEST_ACTIVITY_STATE", "current_stage": "REQUEST_CURRENT_STAGE",
125
+ "clock_settings": "REQUEST_CLOCK_SETTINGS", "transmission_mode": "REQUEST_TRANSMISSION_MODE",
126
+ }
127
+
128
+
129
+ def request(name: str) -> bytes:
130
+ return _u8(COMMANDS[_REQUESTS[name]])
131
+
132
+
133
+ # transport-level: enable the toy-IC to stream responses (raw SSI0 ENABLE_RX)
134
+ ENABLE_RX = bytes([0x01, 0x50, 0x01])
@@ -0,0 +1,63 @@
1
+ """
2
+ Session-key handshake — pure Python.
3
+
4
+ Reimplements the device's native MPID handshake (ECDH P-256 + a 100-round
5
+ AES-128-CTR "mattel" stretch), validated bit-exact against golden vectors.
6
+ Depends only on `cryptography`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from cryptography.hazmat.primitives.asymmetric import ec
13
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
14
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
15
+
16
+ _CURVE = ec.SECP256R1()
17
+ _MATTEL = bytes([0x6D, 0x61, 0x74, 0x74, 0x65, 0x6C]) # "mattel"
18
+
19
+
20
+ def _aes_ctr(key16: bytes, iv16: bytes, data: bytes) -> bytes:
21
+ return Cipher(algorithms.AES(key16), modes.CTR(iv16)).encryptor().update(bytes(data))
22
+
23
+
24
+ def generate_keypair():
25
+ """Ephemeral P-256 keypair. Returns (private_key, compressed_pubkey_33B)."""
26
+ priv = ec.generate_private_key(_CURVE)
27
+ pub = priv.public_key().public_bytes(Encoding.X962, PublicFormat.CompressedPoint)
28
+ return priv, pub
29
+
30
+
31
+ def ecdh_shared_secret(private_key, peer_pub_compressed: bytes) -> bytes:
32
+ """ECDH shared secret (X coordinate, 32B). Accepts the peer's compressed public key."""
33
+ peer = ec.EllipticCurvePublicKey.from_encoded_point(_CURVE, bytes(peer_pub_compressed))
34
+ return private_key.exchange(ec.ECDH(), peer)
35
+
36
+
37
+ def stretch(shared32: bytes) -> bytes:
38
+ """100 rounds of AES-128-CTR: key = shared[:16], IV = counter + 'mattel'."""
39
+ s = bytearray(shared32)
40
+ for counter in range(100):
41
+ iv = bytes([0, 0, 0, 0, 0, 0, 0, counter, 0]) + _MATTEL + bytes([0])
42
+ s = bytearray(_aes_ctr(bytes(s[:16]), iv, bytes(s)))
43
+ return bytes(s)
44
+
45
+
46
+ def derive_session_key(private_key, device_pub_compressed: bytes) -> bytes:
47
+ """32-byte session key (first 16 bytes are the AES-128 data-channel key)."""
48
+ return stretch(ecdh_shared_secret(private_key, device_pub_compressed))
49
+
50
+
51
+ def new_nonce() -> bytes:
52
+ """4 random bytes (the app 'salt' sent in the SESSION payload)."""
53
+ return os.urandom(4)
54
+
55
+
56
+ def token_device_pubkey(token: bytes) -> bytes:
57
+ """Device compressed public key: bytes [25:58] of the MFG token."""
58
+ return bytes(token[25:58])
59
+
60
+
61
+ def token_device_salt(token: bytes) -> bytes:
62
+ """Device salt: last 4 bytes of the MFG token."""
63
+ return bytes(token[-4:])
@@ -0,0 +1,87 @@
1
+ """MPID framing and AES-128-CTR data channel. See docs/protocol.md."""
2
+ from __future__ import annotations
3
+
4
+ import struct
5
+
6
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
7
+
8
+ SSI0_ID = 0x01
9
+ SPI_WRITE = 0x01
10
+
11
+ # CRC-8, polynomial 0x07
12
+ _CRC8 = []
13
+ for _i in range(256):
14
+ _c = _i
15
+ for _ in range(8):
16
+ _c = ((_c << 1) ^ 0x07) & 0xFF if (_c & 0x80) else (_c << 1) & 0xFF
17
+ _CRC8.append(_c)
18
+
19
+
20
+ def crc8(data: bytes, init: int = 0xFF) -> int:
21
+ c = init
22
+ for b in data:
23
+ c = _CRC8[(b ^ c) & 0xFF]
24
+ return c
25
+
26
+
27
+ def aes128_ctr(key16: bytes, iv16: bytes, data: bytes) -> bytes:
28
+ return Cipher(algorithms.AES(key16), modes.CTR(iv16)).encryptor().update(bytes(data))
29
+
30
+
31
+ # ---- application framing ----
32
+ def compose_request(app_data: bytes) -> bytes:
33
+ """FE-frame: 0xFE | len | app_data | XOR(len ^ app_data...)."""
34
+ xor = len(app_data) & 0xFF
35
+ for b in app_data:
36
+ xor ^= b
37
+ return bytes([0xFE, len(app_data) & 0xFF]) + bytes(app_data) + bytes([xor & 0xFF])
38
+
39
+
40
+ def ssi0_wrap(fe_frame: bytes, address: int = 0) -> bytes:
41
+ return bytes([SSI0_ID, ((SPI_WRITE << 4) & 0xF0) | (address & 0x0F)]) + fe_frame
42
+
43
+
44
+ def encode_command(app_data: bytes) -> bytes:
45
+ """[opcode] + args -> MPID plaintext (01 10 | FE-frame)."""
46
+ return ssi0_wrap(compose_request(app_data))
47
+
48
+
49
+ def parse_response_frame(plaintext: bytes) -> dict:
50
+ """Decode an MPID plaintext response: strip SSI header, then the FE-frame."""
51
+ d = plaintext
52
+ ssi = None
53
+ if len(d) >= 2 and d[0] in (0x01, 0x02):
54
+ ssi = d[:2].hex()
55
+ d = d[2:]
56
+ fe = d.find(b"\xFE")
57
+ if fe >= 0 and len(d) >= fe + 3:
58
+ d = d[fe:]
59
+ length = d[1]
60
+ body = d[2:2 + length]
61
+ return {"ssi": ssi, "ok": True,
62
+ "opcode": body[0] if body else None,
63
+ "args": bytes(body[1:]) if len(body) > 1 else b""}
64
+ return {"ssi": ssi, "ok": False}
65
+
66
+
67
+ # ---- MPID frame + AES-128-CTR ----
68
+ def _iv(seq: int, a4: bytes, b4: bytes) -> bytes:
69
+ return struct.pack(">I", seq & 0xFFFFFFFF) + a4 + b4 + b"\x00\x00\x00\x00"
70
+
71
+
72
+ def build_tx_frame(plaintext: bytes, seq: int, key16: bytes, nonce4: bytes, dev_salt4: bytes) -> bytes:
73
+ """App -> device frame. IV = seq || appNonce || deviceSalt || 0."""
74
+ h0 = bytes([0x7E]) + struct.pack(">I", seq & 0xFFFFFFFF) + struct.pack(">H", (len(plaintext) + 1) & 0xFFFF)
75
+ header = h0 + bytes([crc8(h0)])
76
+ body = bytes(plaintext) + bytes([crc8(plaintext)])
77
+ return header + aes128_ctr(key16, _iv(seq, nonce4, dev_salt4), body)
78
+
79
+
80
+ def decrypt_rx_frame(frame: bytes, key16: bytes, nonce4: bytes, dev_salt4: bytes) -> dict | None:
81
+ """Device -> app frame. IV = seq || deviceSalt || appNonce || 0."""
82
+ if len(frame) < 9 or frame[0] != 0x7E:
83
+ return None
84
+ seq = struct.unpack(">I", frame[1:5])[0]
85
+ dec = aes128_ctr(key16, _iv(seq, dev_salt4, nonce4), frame[8:])
86
+ plaintext, crc = dec[:-1], (dec[-1] if dec else 0)
87
+ return {"seq": seq, "plaintext": plaintext, "crc_ok": crc8(plaintext) == crc}
@@ -0,0 +1,52 @@
1
+ """Decode device responses. Values are raw integers (canonical); use label() for names."""
2
+ from __future__ import annotations
3
+
4
+ from ._generated import RESPONSES, OperationMode, Stage, Color
5
+
6
+
7
+ def _nibbles(data: bytes):
8
+ out = []
9
+ for b in data:
10
+ out.append(b >> 4)
11
+ out.append(b & 0x0F)
12
+ return out
13
+
14
+
15
+ def parse_global_state(args: bytes) -> dict:
16
+ """Decode the GLOBAL_STATE snapshot (response 0x02) into raw integer fields."""
17
+ n = _nibbles(args)
18
+ while len(n) < 26:
19
+ n.append(0)
20
+ return {
21
+ "operationMode": n[0], "activityState": n[1], "musicStatus": n[2],
22
+ "currentSong": (n[3] << 4) | n[4], "currentVolume": n[5], "playlistDuration": n[6],
23
+ "lightStatus": n[7], "lightBrightness": n[8], "lightColor": n[9],
24
+ "napTimeStatus": n[10], "napDuration": n[11], "ready2RiseStatus": n[12],
25
+ "ready2RiseAlarmStatus": n[13], "timePrescaler": n[14], "currentStage": n[15],
26
+ "clockDisplay": n[16], "clockBrightness": n[17], "clockFormat": n[18],
27
+ "routineMusicStatus": n[19], "taskRewardSfx": n[20], "routineRewardSfx": n[21],
28
+ "lightDuration": n[22], "routineVolume": n[23], "routineModeStatus": n[24],
29
+ "alarmExecuting": n[25],
30
+ }
31
+
32
+
33
+ def response_name(opcode: int) -> str:
34
+ return RESPONSES.get(opcode, f"0x{opcode:02x}")
35
+
36
+
37
+ def label(state: dict) -> dict:
38
+ """Add human-readable labels to a decoded GLOBAL_STATE (non-destructive)."""
39
+ out = dict(state)
40
+ try:
41
+ out["operationModeLabel"] = OperationMode(state["operationMode"]).name
42
+ except ValueError:
43
+ pass
44
+ try:
45
+ out["currentStageLabel"] = Stage(state["currentStage"]).name
46
+ except ValueError:
47
+ pass
48
+ try:
49
+ out["lightColorLabel"] = Color(state["lightColor"]).name
50
+ except ValueError:
51
+ pass
52
+ return out
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: lumalou
3
+ Version: 0.1.0
4
+ Summary: Local BLE control for the Fisher-Price Lumalou — reverse-engineered MPID protocol, pure Python
5
+ Author: Emanuele Strazzullo
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/stramanu/lumalou
8
+ Project-URL: Issues, https://github.com/stramanu/lumalou/issues
9
+ Keywords: fisher-price,lumalou,ble,bluetooth,bleak,reverse-engineering,gld09
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Home Automation
15
+ Classifier: Framework :: AsyncIO
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: bleak>=0.22
19
+ Requires-Dist: cryptography>=41
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == "dev"
22
+
23
+ # lumalou (Python)
24
+
25
+ Local BLE control for the Fisher-Price Lumalou. Pure Python (async, [bleak](https://github.com/hbldh/bleak)).
26
+
27
+ ```bash
28
+ pip install lumalou
29
+ ```
30
+
31
+ ## Library
32
+
33
+ ```python
34
+ import asyncio
35
+ from lumalou import LumalouClient, Color
36
+
37
+ async def main():
38
+ devices = await LumalouClient.scan()
39
+ async with LumalouClient(devices[0]["address"]) as luma:
40
+ await luma.light_color(Color.RAINBOW)
41
+ await luma.play(0) # guided sleep playlist
42
+ await luma.volume(5)
43
+ print(await luma.request_state())
44
+
45
+ asyncio.run(main())
46
+ ```
47
+
48
+ ## CLI
49
+
50
+ ```bash
51
+ lumalou scan
52
+ lumalou light --color blue --brightness 7
53
+ lumalou sound --source ocean --volume 5
54
+ lumalou soother # --off to stop
55
+ lumalou time # sync clock
56
+ lumalou state # read full state
57
+ lumalou send 3c05 # raw app_data (opcode + args)
58
+ ```
59
+
60
+ Add `-a <address>` to target a device; otherwise it auto-scans.
61
+
62
+ Requires Python 3.10+, a Bluetooth LE adapter, and radio range of the device.
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ pip install -e ".[dev]"
68
+ pytest # runs the shared golden vectors (spec/vectors.json)
69
+ ```
70
+
71
+ MIT.
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/lumalou/__init__.py
4
+ src/lumalou/_generated.py
5
+ src/lumalou/cli.py
6
+ src/lumalou/client.py
7
+ src/lumalou/commands.py
8
+ src/lumalou/crypto.py
9
+ src/lumalou/protocol.py
10
+ src/lumalou/responses.py
11
+ src/lumalou.egg-info/PKG-INFO
12
+ src/lumalou.egg-info/SOURCES.txt
13
+ src/lumalou.egg-info/dependency_links.txt
14
+ src/lumalou.egg-info/entry_points.txt
15
+ src/lumalou.egg-info/requires.txt
16
+ src/lumalou.egg-info/top_level.txt
17
+ tests/test_vectors.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lumalou = lumalou.cli:main
@@ -0,0 +1,5 @@
1
+ bleak>=0.22
2
+ cryptography>=41
3
+
4
+ [dev]
5
+ pytest>=8
@@ -0,0 +1 @@
1
+ lumalou
@@ -0,0 +1,58 @@
1
+ """Golden-vector conformance: the Python binding must match spec/vectors.json.
2
+
3
+ This is the cross-language contract: every binding runs the same vectors.
4
+ """
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from cryptography.hazmat.primitives.asymmetric import ec
10
+
11
+ from lumalou import crypto, protocol as P, responses as R
12
+
13
+ VECTORS = json.loads((Path(__file__).resolve().parents[3] / "spec" / "vectors.json").read_text())
14
+
15
+
16
+ @pytest.mark.parametrize("v", VECTORS["crc8"])
17
+ def test_crc8(v):
18
+ assert P.crc8(bytes.fromhex(v["data"]), v["init"]) == v["expected"]
19
+
20
+
21
+ @pytest.mark.parametrize("v", VECTORS["sessionKey"])
22
+ def test_session_key(v):
23
+ priv = ec.derive_private_key(int(v["privateScalar"], 16), ec.SECP256R1())
24
+ key = crypto.derive_session_key(priv, bytes.fromhex(v["devicePubCompressed"]))
25
+ assert key.hex() == v["expected"]
26
+
27
+
28
+ @pytest.mark.parametrize("v", VECTORS["encodeCommand"])
29
+ def test_encode_command(v):
30
+ assert P.encode_command(bytes.fromhex(v["appData"])).hex() == v["expected"]
31
+
32
+
33
+ @pytest.mark.parametrize("v", VECTORS["txFrame"])
34
+ def test_tx_frame(v):
35
+ frame = P.build_tx_frame(bytes.fromhex(v["plaintext"]), v["seq"],
36
+ bytes.fromhex(v["key"]), bytes.fromhex(v["nonce"]), bytes.fromhex(v["salt"]))
37
+ assert frame.hex() == v["expected"]
38
+
39
+
40
+ @pytest.mark.parametrize("v", VECTORS["rxDecrypt"])
41
+ def test_rx_decrypt(v):
42
+ res = P.decrypt_rx_frame(bytes.fromhex(v["frame"]), bytes.fromhex(v["key"]),
43
+ bytes.fromhex(v["nonce"]), bytes.fromhex(v["salt"]))
44
+ assert res is not None
45
+ assert res["plaintext"].hex() == v["expectedPlaintext"]
46
+ assert res["crc_ok"] == v["expectedCrcOk"]
47
+
48
+
49
+ @pytest.mark.parametrize("v", VECTORS["globalState"])
50
+ def test_global_state(v):
51
+ assert R.parse_global_state(bytes.fromhex(v["args"])) == v["expected"]
52
+
53
+
54
+ @pytest.mark.parametrize("v", VECTORS["responseFrame"])
55
+ def test_response_frame(v):
56
+ r = P.parse_response_frame(bytes.fromhex(v["plaintext"]))
57
+ assert r["ok"] and r["opcode"] == v["expectedOpcode"]
58
+ assert r["args"].hex() == v["expectedArgs"]