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.
@@ -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
+ ]
@@ -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}"
@@ -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"