openble-muse 0.1.0__py3-none-any.whl
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.
- openble/muse/__init__.py +10 -0
- openble/muse/ble4.py +159 -0
- openble/muse/commands.py +74 -0
- openble/muse/identify.py +43 -0
- openble/muse/link.py +196 -0
- openble/muse/models.py +120 -0
- openble/muse/packets.py +162 -0
- openble/muse/readings.py +78 -0
- openble/muse/replies.py +150 -0
- openble/muse/universal.py +174 -0
- openble/muse/uuids.py +63 -0
- openble_muse-0.1.0.dist-info/METADATA +108 -0
- openble_muse-0.1.0.dist-info/RECORD +15 -0
- openble_muse-0.1.0.dist-info/WHEEL +4 -0
- openble_muse-0.1.0.dist-info/licenses/LICENSE +21 -0
openble/muse/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Interaxon Muse headband BLE protocol, read from the Muse app and public work.
|
|
2
|
+
|
|
3
|
+
``uuids`` names the GATT characteristics, ``models`` maps a serial number to
|
|
4
|
+
a model and its characteristics, ``identify`` matches advertisements,
|
|
5
|
+
``commands`` frames the ASCII commands and ``replies`` reassembles and reads
|
|
6
|
+
the JSON answers. The data layouts are ``packets`` (per-sensor, ``pv`` 1-2),
|
|
7
|
+
``universal`` (``pv`` 3) and ``ble4`` (``pv`` >= 4). ``link`` runs the
|
|
8
|
+
start-up sequence over any transport. Nothing here imports a Bluetooth stack.
|
|
9
|
+
See docs/muse-ble-protocol.md.
|
|
10
|
+
"""
|
openble/muse/ble4.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The BLE4 stream (protocol version 4 and up): current Muse S Athena firmware.
|
|
2
|
+
|
|
3
|
+
Read from Ble4DataHandler::handle, parse_subpackets and ParsedPacket::convert;
|
|
4
|
+
the tags and payload sizes match amused, OpenMuse and muse-rs.
|
|
5
|
+
|
|
6
|
+
frame: len | counter u16 LE | device clock u48 LE | sub-packets…
|
|
7
|
+
sub-packet: tag | seq u8 | time offset s24 LE | payload
|
|
8
|
+
|
|
9
|
+
Frames arrive on ``UNIVERSAL`` and ``MUSE_DEBUG``. The app requires one frame
|
|
10
|
+
per notification; OpenMuse walks several by their length bytes, so
|
|
11
|
+
``parse_notification`` does both. Values are packed least-significant bit
|
|
12
|
+
first, and rows are time steps (sample-major), as amused, OpenMuse and
|
|
13
|
+
muse-rs decode them.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from openble.muse.readings import DPS_PER_BIT, G_PER_BIT, Reading, eeg14_uv
|
|
21
|
+
|
|
22
|
+
HEADER = 9
|
|
23
|
+
EEG4, EEG8, DRL_REF, OPTICS4, OPTICS8, OPTICS16, IMU, BATTERY = range(1, 9)
|
|
24
|
+
RATES_HZ = {1: 256, 2: 128, 3: 64, 4: 52, 5: 32, 6: 16, 7: 10, 8: 1, 9: 0.1}
|
|
25
|
+
PAYLOAD = {1: 28, 2: 28, 3: 24, 4: 30, 5: 40, 6: 40, 7: 36, 8: 20}
|
|
26
|
+
BATTERY_TO_END = 0x88 # newer firmware: 188-230 bytes to the end of the frame
|
|
27
|
+
|
|
28
|
+
# Channel names are public (muse-rs); the app does not name them.
|
|
29
|
+
EEG_CHANNELS = ("TP9", "AF7", "AF8", "TP10", "FPZ", "AUX_RIGHT", "AUX_LEFT", "AUX")
|
|
30
|
+
IMU_CHANNELS = ("accel_x", "accel_y", "accel_z", "gyro_x", "gyro_y", "gyro_z")
|
|
31
|
+
OPTICS_CHANNELS = {OPTICS4: 4, OPTICS8: 8, OPTICS16: 16}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SubPacket:
|
|
36
|
+
tag: int
|
|
37
|
+
kind: int # low nibble
|
|
38
|
+
rate_code: int # high nibble
|
|
39
|
+
sequence: int # per type; the app skips duplicates and logs gaps
|
|
40
|
+
offset: int # s24 LE; the app multiplies it by 100
|
|
41
|
+
payload: bytes
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Frame:
|
|
46
|
+
counter: int # u16 LE; the app reorders notifications by it
|
|
47
|
+
clock: int # u48 LE; muse-rs reads 256 kHz ticks
|
|
48
|
+
subpackets: tuple[SubPacket, ...]
|
|
49
|
+
unparsed: bytes # from the first unknown or truncated sub-packet on
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_frame(data: bytes) -> Frame:
|
|
53
|
+
"""Split one frame. Byte 0 must equal its length."""
|
|
54
|
+
data = bytes(data)
|
|
55
|
+
if len(data) < HEADER or data[0] != len(data):
|
|
56
|
+
raise ValueError(f"BLE4 frame length mismatch: {data.hex(' ')}")
|
|
57
|
+
subpackets: list[SubPacket] = []
|
|
58
|
+
i = HEADER
|
|
59
|
+
while i + 5 <= len(data):
|
|
60
|
+
tag = data[i]
|
|
61
|
+
kind = tag & 0x0F
|
|
62
|
+
size = len(data) - i - 5 if tag == BATTERY_TO_END else PAYLOAD.get(kind)
|
|
63
|
+
if size is None or i + 5 + size > len(data):
|
|
64
|
+
break
|
|
65
|
+
subpackets.append(
|
|
66
|
+
SubPacket(
|
|
67
|
+
tag=tag,
|
|
68
|
+
kind=kind,
|
|
69
|
+
rate_code=tag >> 4,
|
|
70
|
+
sequence=data[i + 1],
|
|
71
|
+
offset=int.from_bytes(data[i + 2 : i + 5], "little", signed=True),
|
|
72
|
+
payload=data[i + 5 : i + 5 + size],
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
i += 5 + size
|
|
76
|
+
return Frame(
|
|
77
|
+
counter=int.from_bytes(data[1:3], "little"),
|
|
78
|
+
clock=int.from_bytes(data[3:9], "little"),
|
|
79
|
+
subpackets=tuple(subpackets),
|
|
80
|
+
unparsed=data[i:],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_notification(data: bytes) -> list[Frame]:
|
|
85
|
+
"""Every frame in a notification, walked by their length bytes."""
|
|
86
|
+
data = bytes(data)
|
|
87
|
+
frames = []
|
|
88
|
+
i = 0
|
|
89
|
+
while i < len(data):
|
|
90
|
+
length = data[i]
|
|
91
|
+
if length < HEADER or i + length > len(data):
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"BLE4 frame at {i} claims {length} bytes: {data.hex(' ')}"
|
|
94
|
+
)
|
|
95
|
+
frames.append(parse_frame(data[i : i + length]))
|
|
96
|
+
i += length
|
|
97
|
+
return frames
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def unpack_lsb_first(payload: bytes, bits: int) -> list[int]:
|
|
101
|
+
"""Unsigned ``bits``-wide fields, least-significant bit first."""
|
|
102
|
+
word = int.from_bytes(payload, "little")
|
|
103
|
+
mask = (1 << bits) - 1
|
|
104
|
+
return [(word >> (bits * i)) & mask for i in range(len(payload) * 8 // bits)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def reading(sub: SubPacket, eeg_offset: int = 0) -> Reading:
|
|
108
|
+
"""Convert a sub-packet. EEG is in microvolts, optics stay raw.
|
|
109
|
+
|
|
110
|
+
``eeg_offset=EEG14_OFFSET_MUSE_RS`` centres EEG the way muse-rs does.
|
|
111
|
+
"""
|
|
112
|
+
rate = RATES_HZ.get(sub.rate_code)
|
|
113
|
+
if sub.kind in (EEG4, EEG8):
|
|
114
|
+
width = 4 if sub.kind == EEG4 else 8
|
|
115
|
+
values = [eeg14_uv(v, eeg_offset) for v in unpack_lsb_first(sub.payload, 14)]
|
|
116
|
+
return Reading(
|
|
117
|
+
"eeg", EEG_CHANNELS[:width], _rows(values, width), sub.sequence, rate
|
|
118
|
+
)
|
|
119
|
+
if sub.kind in OPTICS_CHANNELS:
|
|
120
|
+
width = OPTICS_CHANNELS[sub.kind]
|
|
121
|
+
values = [float(v) for v in unpack_lsb_first(sub.payload, 20)]
|
|
122
|
+
channels = tuple(f"optics_{n}" for n in range(width))
|
|
123
|
+
return Reading("optics", channels, _rows(values, width), sub.sequence, rate)
|
|
124
|
+
if sub.kind == IMU:
|
|
125
|
+
v = _s16(sub.payload)
|
|
126
|
+
rows = tuple(
|
|
127
|
+
tuple(x * G_PER_BIT for x in v[i : i + 3])
|
|
128
|
+
+ tuple(x * DPS_PER_BIT for x in v[i + 3 : i + 6])
|
|
129
|
+
for i in range(0, 18, 6)
|
|
130
|
+
)
|
|
131
|
+
return Reading("imu", IMU_CHANNELS, rows, sub.sequence, rate)
|
|
132
|
+
if sub.kind == DRL_REF:
|
|
133
|
+
values = [float(x) for x in _s16(sub.payload)]
|
|
134
|
+
return Reading("drl_ref", ("drl", "ref"), _rows(values, 2), sub.sequence, rate)
|
|
135
|
+
if sub.kind == BATTERY:
|
|
136
|
+
# Only the first field is known: charge percent as u16 LE / 256.
|
|
137
|
+
percent = int.from_bytes(sub.payload[:2], "little") / 256
|
|
138
|
+
return Reading("battery", ("percent",), ((percent,),), sub.sequence, rate)
|
|
139
|
+
raise ValueError(f"no reading for sub-packet type {sub.kind}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def readings(data: bytes, eeg_offset: int = 0) -> list[Reading]:
|
|
143
|
+
"""Every reading in a notification, in order."""
|
|
144
|
+
return [
|
|
145
|
+
reading(sub, eeg_offset)
|
|
146
|
+
for frame in parse_notification(data)
|
|
147
|
+
for sub in frame.subpackets
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _rows(values: list[float], width: int) -> tuple[tuple[float, ...], ...]:
|
|
152
|
+
return tuple(tuple(values[i : i + width]) for i in range(0, len(values), width))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _s16(payload: bytes) -> list[int]:
|
|
156
|
+
return [
|
|
157
|
+
int.from_bytes(payload[i : i + 2], "little", signed=True)
|
|
158
|
+
for i in range(0, len(payload) - 1, 2)
|
|
159
|
+
]
|
openble/muse/commands.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""ASCII commands on ``SERIAL`` and their length-prefixed framing.
|
|
2
|
+
|
|
3
|
+
Builders follow ``interaxon::cmd::nevis``; framing is ``ConnectorImpl::send``:
|
|
4
|
+
one byte ``n`` (the command bytes that follow, ``\\n`` included, at most 19),
|
|
5
|
+
then the ASCII command ending in ``\\n``. ``s`` goes out as ``02 73 0a``.
|
|
6
|
+
Builders return the command text; ``encode`` frames it for the write.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from openble.muse.models import PRESETS
|
|
12
|
+
|
|
13
|
+
MAX_PAYLOAD = 19 # command bytes after the length byte, "\n" included
|
|
14
|
+
|
|
15
|
+
STATUS = "s"
|
|
16
|
+
HALT = "h"
|
|
17
|
+
KEEP_ALIVE = "k" # every 2.5 s while paused
|
|
18
|
+
BOOT_TO_HEADSET = "*1" # leave the bootloader
|
|
19
|
+
BOOT_TO_BOOTLOADER = "*0" # for firmware update; no reply
|
|
20
|
+
LED_ON = "L1" # MU_04, MU_05 and MS_03 only; no reply
|
|
21
|
+
LED_OFF = "L0"
|
|
22
|
+
START_FLAGS = 0xC001 # meaning not known; public clients send it too
|
|
23
|
+
START_LEGACY = "d" # what muse-js and the per-sensor path in muse-rs send
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def encode(command: str) -> bytes:
|
|
27
|
+
"""``[n][command]\\n`` as one write. Raises if it exceeds 19 bytes."""
|
|
28
|
+
body = (command + "\n").encode("ascii")
|
|
29
|
+
if len(body) > MAX_PAYLOAD:
|
|
30
|
+
raise ValueError(f"{command!r} is {len(body)} bytes with '\\n'; max 19")
|
|
31
|
+
return bytes((len(body),)) + body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def expects_reply(command: str) -> bool:
|
|
35
|
+
"""False for the fire-and-forget commands, which start with ``L`` or ``Q``."""
|
|
36
|
+
return not command.startswith(("L", "Q"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def version(offered: int = 7) -> str:
|
|
40
|
+
"""``v<n>``: the version query, offering protocol version ``n`` in hex.
|
|
41
|
+
|
|
42
|
+
The app sends ``v7``. Public clients send ``v1``, ``v4`` or ``v6``.
|
|
43
|
+
"""
|
|
44
|
+
if not 0 <= offered <= 0xF:
|
|
45
|
+
raise ValueError("offered protocol version is one hex digit")
|
|
46
|
+
return f"v{offered:x}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def preset(code: str) -> str:
|
|
50
|
+
"""``p<code>``, e.g. ``p21`` or ``p1035``. Halt streaming first."""
|
|
51
|
+
code = code.upper()
|
|
52
|
+
if code not in PRESETS:
|
|
53
|
+
raise ValueError(f"unknown preset {code!r}")
|
|
54
|
+
return "p" + code
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def start_streaming(flags: int = START_FLAGS) -> str:
|
|
58
|
+
"""``d`` + flags in hex, ``dc001``. Older clients send ``START_LEGACY``."""
|
|
59
|
+
return f"d{flags:x}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def led(on: bool) -> str:
|
|
63
|
+
return LED_ON if on else LED_OFF
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clock_sync(index: int, host_ms: int) -> str:
|
|
67
|
+
"""``Q<k> C,<12 hex digits>``: the universal v3 clock-sync answer.
|
|
68
|
+
|
|
69
|
+
``host_ms`` is wall-clock milliseconds, most significant digit first.
|
|
70
|
+
What ``k`` counts is not recorded; this formats it in decimal.
|
|
71
|
+
"""
|
|
72
|
+
if not 0 <= host_ms < 1 << 48:
|
|
73
|
+
raise ValueError("host time is 48 bits")
|
|
74
|
+
return f"Q{index} C,{host_ms:012x}"
|
openble/muse/identify.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Match an advertisement to a Muse headband (MuseManagerAndroid, LeDevice)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from openble.muse.models import GENERATIONS
|
|
8
|
+
from openble.muse.uuids import SERVICE
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def classify(name: str, service_uuids: list[str]) -> tuple[list[str], list[str]]:
|
|
12
|
+
"""Return ``(["headband"], reasons)`` for a Muse headband, else empty lists.
|
|
13
|
+
|
|
14
|
+
The app scans for service ``0xFE8D``; a name starting ``Muse`` also counts.
|
|
15
|
+
"""
|
|
16
|
+
reasons: list[str] = []
|
|
17
|
+
if SERVICE in {uuid.lower() for uuid in service_uuids}:
|
|
18
|
+
reasons.append("service fe8d")
|
|
19
|
+
if name.startswith("Muse"):
|
|
20
|
+
reasons.append(f"name {name}")
|
|
21
|
+
return (["headband"] if reasons else []), reasons
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generation(manufacturer_data: Mapping[int, bytes]) -> str | None:
|
|
25
|
+
"""Hardware generation from byte 1 of the first manufacturer data entry.
|
|
26
|
+
|
|
27
|
+
Any company ID is accepted. Android and bleak both hand over the value
|
|
28
|
+
without the two company ID bytes. Returns ``aster``, ``letto``,
|
|
29
|
+
``athena``, or None when absent or unknown.
|
|
30
|
+
"""
|
|
31
|
+
for value in manufacturer_data.values():
|
|
32
|
+
return GENERATIONS.get(value[1]) if len(value) > 1 else None
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fallback_name(address: str, hardware: str | None = None) -> str:
|
|
37
|
+
"""The name the app shows when the OS reports none.
|
|
38
|
+
|
|
39
|
+
``Muse-`` (``MuseS-`` for generations 5-7) plus the last four hex digits
|
|
40
|
+
of the address. The status reply's ``hn`` replaces it after connecting.
|
|
41
|
+
"""
|
|
42
|
+
digits = address.replace(":", "").replace("-", "").upper()[-4:]
|
|
43
|
+
return ("MuseS-" if hardware in GENERATIONS.values() else "Muse-") + digits
|
openble/muse/link.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The command link and the app's start-up sequence, over any transport.
|
|
2
|
+
|
|
3
|
+
``nevis::ConnectorImpl`` keeps one command in flight and waits for its JSON
|
|
4
|
+
reply before the next, except for ``L…`` and ``Q…``, which get none.
|
|
5
|
+
``MuseLink`` copies that: it takes a ``write`` coroutine and is fed the
|
|
6
|
+
``SERIAL`` notifications, so it runs over bleak, a test double, or anything
|
|
7
|
+
else. ``prepare`` runs states 3-8 of the app's path to streaming.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from openble.muse import commands, models, uuids
|
|
18
|
+
from openble.muse.replies import (
|
|
19
|
+
MuseError,
|
|
20
|
+
Replies,
|
|
21
|
+
Status,
|
|
22
|
+
Version,
|
|
23
|
+
check_protocol,
|
|
24
|
+
parse_status,
|
|
25
|
+
parse_version,
|
|
26
|
+
return_code,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# interaxon::cmd::*_TIMEOUT, in seconds.
|
|
30
|
+
VERSION_TIMEOUT = 10.0
|
|
31
|
+
STATUS_TIMEOUT = 8.0
|
|
32
|
+
SERIAL_TIMEOUT = 6.0
|
|
33
|
+
KEEP_ALIVE_INTERVAL = 2.5 # while paused
|
|
34
|
+
DATA_TIMEOUT = 6.0 # no data while streaming for this long is an error
|
|
35
|
+
|
|
36
|
+
LICENCE_INVALID = 2 # dc001 rc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BootloaderError(MuseError):
|
|
40
|
+
"""The headband runs its bootloader. Send ``*1`` (``boot_to_headset``) and reconnect."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Session:
|
|
45
|
+
"""What ``prepare`` learned, and what to subscribe to before ``start_streaming``."""
|
|
46
|
+
|
|
47
|
+
version: Version
|
|
48
|
+
status: Status
|
|
49
|
+
model: str
|
|
50
|
+
preset: str
|
|
51
|
+
layout: str # "per-sensor", "universal" or "ble4"
|
|
52
|
+
# Data characteristics to subscribe to. TIME_SYNC is left out: its message
|
|
53
|
+
# format is still open.
|
|
54
|
+
subscribe: tuple[str, ...]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MuseLink:
|
|
58
|
+
"""Send commands to one headband and match its JSON replies.
|
|
59
|
+
|
|
60
|
+
``write`` sends one framed command to ``SERIAL``. The app writes without
|
|
61
|
+
response where the characteristic allows it. Call ``feed`` from the
|
|
62
|
+
``SERIAL`` notification callback.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
write: Callable[[bytes], Awaitable[None]],
|
|
68
|
+
*,
|
|
69
|
+
log: Callable[..., None] | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._write = write
|
|
72
|
+
self._log = log or (lambda kind, **payload: None)
|
|
73
|
+
self._replies = Replies()
|
|
74
|
+
self._lock = asyncio.Lock()
|
|
75
|
+
self._pending: asyncio.Future[dict[str, Any]] | None = None
|
|
76
|
+
|
|
77
|
+
def feed(self, data: bytes) -> None:
|
|
78
|
+
"""Take one ``SERIAL`` notification. Safe to call from a bleak callback."""
|
|
79
|
+
reply = self._replies.feed(bytes(data))
|
|
80
|
+
if reply is None:
|
|
81
|
+
return
|
|
82
|
+
self._log("reply", message=str(reply), data=reply)
|
|
83
|
+
if self._pending is not None and not self._pending.done():
|
|
84
|
+
self._pending.set_result(reply)
|
|
85
|
+
else:
|
|
86
|
+
self._log("unsolicited", message=str(reply), data=reply)
|
|
87
|
+
|
|
88
|
+
async def command(
|
|
89
|
+
self, command: str, timeout: float = SERIAL_TIMEOUT
|
|
90
|
+
) -> dict[str, Any] | None:
|
|
91
|
+
"""Write one command and return its reply, or None for ``L…``/``Q…``."""
|
|
92
|
+
payload = commands.encode(command)
|
|
93
|
+
async with self._lock:
|
|
94
|
+
if not commands.expects_reply(command):
|
|
95
|
+
self._log("write", message=command, data=payload)
|
|
96
|
+
await self._write(payload)
|
|
97
|
+
return None
|
|
98
|
+
self._pending = asyncio.get_running_loop().create_future()
|
|
99
|
+
try:
|
|
100
|
+
self._log("write", message=command, data=payload)
|
|
101
|
+
await self._write(payload)
|
|
102
|
+
async with asyncio.timeout(timeout):
|
|
103
|
+
return await self._pending
|
|
104
|
+
except TimeoutError:
|
|
105
|
+
self._replies.reset()
|
|
106
|
+
raise TimeoutError(f"no reply to {command!r} in {timeout}s") from None
|
|
107
|
+
finally:
|
|
108
|
+
self._pending = None
|
|
109
|
+
|
|
110
|
+
async def version(
|
|
111
|
+
self, offered: int = 7, timeout: float = VERSION_TIMEOUT
|
|
112
|
+
) -> Version:
|
|
113
|
+
return parse_version(await self._reply(commands.version(offered), timeout))
|
|
114
|
+
|
|
115
|
+
async def status(self, timeout: float = STATUS_TIMEOUT) -> Status:
|
|
116
|
+
return parse_status(await self._reply(commands.STATUS, timeout))
|
|
117
|
+
|
|
118
|
+
async def halt(self, timeout: float = SERIAL_TIMEOUT) -> None:
|
|
119
|
+
"""``h``. The app drops the connection on anything but ``{"rc":0}``."""
|
|
120
|
+
reply = await self._reply(commands.HALT, timeout)
|
|
121
|
+
if return_code(reply) != 0:
|
|
122
|
+
raise MuseError(f"halt failed: {reply}")
|
|
123
|
+
|
|
124
|
+
async def set_preset(self, code: str, timeout: float = SERIAL_TIMEOUT) -> None:
|
|
125
|
+
"""``p<code>``. The headband refuses while streaming, so halt first."""
|
|
126
|
+
reply = await self._reply(commands.preset(code), timeout)
|
|
127
|
+
if return_code(reply) != 0:
|
|
128
|
+
raise MuseError(
|
|
129
|
+
f"preset {code} refused (wrong model, or streaming): {reply}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async def start_streaming(
|
|
133
|
+
self, command: str | None = None, timeout: float = SERIAL_TIMEOUT
|
|
134
|
+
) -> int:
|
|
135
|
+
"""``dc001`` (or ``command``, e.g. ``commands.START_LEGACY``); returns ``rc``.
|
|
136
|
+
|
|
137
|
+
``rc`` 2 is an invalid headband licence and raises. Any other
|
|
138
|
+
non-zero ``rc`` means it was already streaming, which the app accepts.
|
|
139
|
+
"""
|
|
140
|
+
reply = await self._reply(command or commands.start_streaming(), timeout)
|
|
141
|
+
rc = return_code(reply)
|
|
142
|
+
if rc == LICENCE_INVALID:
|
|
143
|
+
raise MuseError(f"the headband licence is not valid: {reply}")
|
|
144
|
+
if rc != 0:
|
|
145
|
+
self._log("already-streaming", message=str(reply), data=reply)
|
|
146
|
+
return rc
|
|
147
|
+
|
|
148
|
+
async def keep_alive(self, timeout: float = SERIAL_TIMEOUT) -> None:
|
|
149
|
+
"""``k``: send every ``KEEP_ALIVE_INTERVAL`` while paused."""
|
|
150
|
+
await self._reply(commands.KEEP_ALIVE, timeout)
|
|
151
|
+
|
|
152
|
+
async def led(self, on: bool) -> None:
|
|
153
|
+
"""``L1``/``L0``; Muse S and Athena only. No reply comes back."""
|
|
154
|
+
await self.command(commands.led(on))
|
|
155
|
+
|
|
156
|
+
async def boot_to_headset(self, timeout: float = SERIAL_TIMEOUT) -> None:
|
|
157
|
+
"""``*1``: leave the bootloader. Reconnect once the headband reboots."""
|
|
158
|
+
reply = await self._reply(commands.BOOT_TO_HEADSET, timeout)
|
|
159
|
+
if return_code(reply) == 1:
|
|
160
|
+
raise MuseError(f"firmware CRC failure: {reply}")
|
|
161
|
+
|
|
162
|
+
async def prepare(
|
|
163
|
+
self, preset: str | None = None, offered_version: int = 7
|
|
164
|
+
) -> Session:
|
|
165
|
+
"""Version, status, halt, preset, status: the app's path up to subscribing.
|
|
166
|
+
|
|
167
|
+
``preset`` defaults to the app's default for the model. Next, subscribe
|
|
168
|
+
to ``Session.subscribe`` and call ``start_streaming``.
|
|
169
|
+
"""
|
|
170
|
+
version = await self.version(offered_version)
|
|
171
|
+
check_protocol(version.protocol)
|
|
172
|
+
if version.in_bootloader:
|
|
173
|
+
raise BootloaderError(f"headband is in its bootloader: {version.raw}")
|
|
174
|
+
model = (await self.status()).model
|
|
175
|
+
code = (preset or models.default_preset(model)).upper()
|
|
176
|
+
await self.halt()
|
|
177
|
+
await self.set_preset(code)
|
|
178
|
+
status = await self.status()
|
|
179
|
+
subscribe = tuple(
|
|
180
|
+
uuid
|
|
181
|
+
for uuid in models.characteristics(model, version.protocol)
|
|
182
|
+
if uuid != uuids.TIME_SYNC
|
|
183
|
+
)
|
|
184
|
+
return Session(
|
|
185
|
+
version=version,
|
|
186
|
+
status=status,
|
|
187
|
+
model=model,
|
|
188
|
+
preset=code,
|
|
189
|
+
layout=models.layout(version.protocol),
|
|
190
|
+
subscribe=subscribe,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async def _reply(self, command: str, timeout: float) -> dict[str, Any]:
|
|
194
|
+
reply = await self.command(command, timeout)
|
|
195
|
+
assert reply is not None
|
|
196
|
+
return reply
|
openble/muse/models.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Models, capability sets and presets from the app's libmuse.
|
|
2
|
+
|
|
3
|
+
The model comes from the first character of the serial number in the status
|
|
4
|
+
reply (``bridge::get_model_from_serial_number``). The capability set decides
|
|
5
|
+
which characteristics carry data (``DeviceCapability::get_characteristics``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from openble.muse import uuids
|
|
11
|
+
|
|
12
|
+
# MuseModel -> (product, capability set). Product names are the common public
|
|
13
|
+
# ones; "inferred" rows come from the capability set alone.
|
|
14
|
+
MODELS = {
|
|
15
|
+
"MU_01": ("Muse 2014", "classic"), # classic Bluetooth, not covered
|
|
16
|
+
"MU_02": ("Muse 2016", "nevis"),
|
|
17
|
+
"MU_03": ("Muse 2", "blackcomb"),
|
|
18
|
+
"MU_06": ("Muse 2, later hardware (inferred)", "blackcomb"),
|
|
19
|
+
"MU_04": ("Muse S", "aster"),
|
|
20
|
+
"MU_05": ("Muse S, later hardware (inferred)", "aster"),
|
|
21
|
+
"MS_03": ("Muse S Athena", "athena"),
|
|
22
|
+
"GL_01": ("internal glasses device", "glasses"),
|
|
23
|
+
"AMHL_01": ("internal, product unknown", "blackcomb"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# cmd::get_default_preset. Public Athena clients use 1041, 1034 or 1045 instead.
|
|
27
|
+
DEFAULT_PRESETS = {
|
|
28
|
+
"MU_01": "14",
|
|
29
|
+
"MU_02": "21",
|
|
30
|
+
"MU_03": "21",
|
|
31
|
+
"MU_06": "21",
|
|
32
|
+
"MU_04": "21",
|
|
33
|
+
"MU_05": "21",
|
|
34
|
+
"MS_03": "1021",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Models that take the L1 / L0 indicator LED commands.
|
|
38
|
+
LED_MODELS = frozenset({"MU_04", "MU_05", "MS_03"})
|
|
39
|
+
|
|
40
|
+
_NEVIS = (0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x000B)
|
|
41
|
+
_NEVIS += (0x000A, 0x0009)
|
|
42
|
+
_BLACKCOMB = _NEVIS + (0x000F, 0x0010, 0x0011)
|
|
43
|
+
CAPABILITIES = {
|
|
44
|
+
"nevis": _NEVIS,
|
|
45
|
+
"blackcomb": _BLACKCOMB,
|
|
46
|
+
"aster": _BLACKCOMB + (0x0012,),
|
|
47
|
+
"athena": (0x0013, 0x0014, 0x0015),
|
|
48
|
+
"glasses": _NEVIS + (0x000C, 0x000D, 0x000E),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# PRESET_MAP in muse_command.cpp, without the Muse 2014 codes.
|
|
52
|
+
PRESETS = frozenset(
|
|
53
|
+
{"20", "21", "22", "23", "31", "32", "50", "51", "52", "53", "54", "55"}
|
|
54
|
+
| {"60", "61", "63"}
|
|
55
|
+
| {f"102{c}" for c in "123456789A"}
|
|
56
|
+
| {f"103{c}" for c in "123456"}
|
|
57
|
+
| {f"104{c}" for c in "123456"}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Hardware generation, byte 1 of the manufacturer data (LeDevice.getInfo).
|
|
61
|
+
GENERATIONS = {5: "aster", 6: "letto", 7: "athena"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def model_from_serial(serial: str) -> str:
|
|
65
|
+
"""``MuseModel`` name for a serial number, e.g. ``"5031-…"`` -> ``"MU_04"``."""
|
|
66
|
+
first = serial[:1]
|
|
67
|
+
if first == "4":
|
|
68
|
+
return "MU_03" if serial[1:2] <= "2" else "MU_06"
|
|
69
|
+
model = {
|
|
70
|
+
"1": "MU_01",
|
|
71
|
+
"2": "MU_02",
|
|
72
|
+
"3": "GL_01",
|
|
73
|
+
"5": "MU_04",
|
|
74
|
+
"6": "MU_05",
|
|
75
|
+
"7": "MS_03",
|
|
76
|
+
"9": "AMHL_01",
|
|
77
|
+
}.get(first)
|
|
78
|
+
if model is None:
|
|
79
|
+
raise ValueError(f"unknown muse model from serial number {serial!r}")
|
|
80
|
+
return model
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def capability(model: str) -> str:
|
|
84
|
+
"""The capability set of a model, e.g. ``"MS_03"`` -> ``"athena"``."""
|
|
85
|
+
try:
|
|
86
|
+
return MODELS[model][1]
|
|
87
|
+
except KeyError:
|
|
88
|
+
raise ValueError(f"unknown model {model!r}") from None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def characteristics(model: str, protocol_version: int) -> tuple[str, ...]:
|
|
92
|
+
"""Data characteristic uuids the app maps for a model, ``SERIAL`` not included.
|
|
93
|
+
|
|
94
|
+
Athena drops ``TIME_SYNC`` below protocol version 5.
|
|
95
|
+
"""
|
|
96
|
+
suffixes = CAPABILITIES.get(capability(model))
|
|
97
|
+
if suffixes is None:
|
|
98
|
+
raise ValueError(f"{model} is not a BLE headband")
|
|
99
|
+
if protocol_version < 5:
|
|
100
|
+
suffixes = tuple(s for s in suffixes if s != 0x0015)
|
|
101
|
+
return tuple(uuids.muse_uuid(s) for s in suffixes)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def default_preset(model: str) -> str:
|
|
105
|
+
"""The preset the app selects for a model."""
|
|
106
|
+
try:
|
|
107
|
+
return DEFAULT_PRESETS[model]
|
|
108
|
+
except KeyError:
|
|
109
|
+
raise ValueError(f"no default preset for {model}") from None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def layout(protocol_version: int) -> str:
|
|
113
|
+
"""``"per-sensor"`` (pv 1-2), ``"universal"`` (pv 3) or ``"ble4"`` (pv >= 4)."""
|
|
114
|
+
if protocol_version < 1:
|
|
115
|
+
raise ValueError(f"protocol version {protocol_version} is below 1")
|
|
116
|
+
if protocol_version <= 2:
|
|
117
|
+
return "per-sensor"
|
|
118
|
+
if protocol_version == 3:
|
|
119
|
+
return "universal"
|
|
120
|
+
return "ble4"
|