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 +71 -0
- lumalou-0.1.0/README.md +49 -0
- lumalou-0.1.0/pyproject.toml +39 -0
- lumalou-0.1.0/setup.cfg +4 -0
- lumalou-0.1.0/src/lumalou/__init__.py +30 -0
- lumalou-0.1.0/src/lumalou/_generated.py +208 -0
- lumalou-0.1.0/src/lumalou/cli.py +111 -0
- lumalou-0.1.0/src/lumalou/client.py +157 -0
- lumalou-0.1.0/src/lumalou/commands.py +134 -0
- lumalou-0.1.0/src/lumalou/crypto.py +63 -0
- lumalou-0.1.0/src/lumalou/protocol.py +87 -0
- lumalou-0.1.0/src/lumalou/responses.py +52 -0
- lumalou-0.1.0/src/lumalou.egg-info/PKG-INFO +71 -0
- lumalou-0.1.0/src/lumalou.egg-info/SOURCES.txt +17 -0
- lumalou-0.1.0/src/lumalou.egg-info/dependency_links.txt +1 -0
- lumalou-0.1.0/src/lumalou.egg-info/entry_points.txt +2 -0
- lumalou-0.1.0/src/lumalou.egg-info/requires.txt +5 -0
- lumalou-0.1.0/src/lumalou.egg-info/top_level.txt +1 -0
- lumalou-0.1.0/tests/test_vectors.py +58 -0
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.
|
lumalou-0.1.0/README.md
ADDED
|
@@ -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"]
|
lumalou-0.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|