emay-sleepo2 1.0.1__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.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: emay-sleepo2
3
+ Version: 1.0.1
4
+ Summary: BLE client and CSV parser for the EMAY SleepO2 pulse oximeter
5
+ Author: Ground Effect Software, LLC
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/chenders/emay-sleepo2
8
+ Project-URL: Repository, https://github.com/chenders/emay-sleepo2
9
+ Project-URL: Issues, https://github.com/chenders/emay-sleepo2/issues
10
+ Project-URL: Specification, https://github.com/chenders/emay-sleepo2/blob/main/spec.md
11
+ Keywords: emay,sleepo2,pulse-oximeter,spo2,ble,bluetooth,health,medical,oximetry,wearable
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Healthcare Industry
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ Provides-Extra: ble
28
+ Requires-Dist: bleak>=0.22; extra == "ble"
29
+ Provides-Extra: csv
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest; extra == "dev"
32
+ Requires-Dist: ruff; extra == "dev"
33
+ Provides-Extra: all
34
+ Requires-Dist: bleak>=0.22; extra == "all"
35
+ Requires-Dist: pytest; extra == "all"
36
+ Requires-Dist: ruff; extra == "all"
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "emay-sleepo2"
7
+ version = "1.0.1"
8
+ description = "BLE client and CSV parser for the EMAY SleepO2 pulse oximeter"
9
+ readme = { file = "../../README.md", content-type = "text/markdown" }
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Ground Effect Software, LLC" }]
12
+ keywords = [
13
+ "emay", "sleepo2", "pulse-oximeter", "spo2", "ble", "bluetooth",
14
+ "health", "medical", "oximetry", "wearable"
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Healthcare Industry",
20
+ "Intended Audience :: Science/Research",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ ]
31
+ requires-python = ">=3.10"
32
+ dependencies = []
33
+
34
+ [project.optional-dependencies]
35
+ ble = ["bleak>=0.22"]
36
+ csv = []
37
+ dev = ["pytest", "ruff"]
38
+ all = ["bleak>=0.22", "pytest", "ruff"]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/chenders/emay-sleepo2"
42
+ Repository = "https://github.com/chenders/emay-sleepo2"
43
+ Issues = "https://github.com/chenders/emay-sleepo2/issues"
44
+ Specification = "https://github.com/chenders/emay-sleepo2/blob/main/spec.md"
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
48
+
49
+ [tool.ruff]
50
+ line-length = 120
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,34 @@
1
+ """EMAY SleepO2 BLE SDK — Python."""
2
+
3
+ from .types import Reading, MinuteSample, Status
4
+ from .protocol import (
5
+ parse_reading, checksum, command,
6
+ HELLO, DEVICE_STATE, START_REALTIME, STOP_REALTIME, GET_BATTERY, HEARTBEAT,
7
+ SERVICE_UUID, WRITE_UUID, NOTIFY_UUID,
8
+ )
9
+ from .downsampler import LiveDownsampler
10
+ from .csv_parser import parse_csv, parse_csv_file
11
+
12
+ # EMAYClient requires bleak (pip install bleak). Import it explicitly
13
+ # to avoid load failures when only CSV parsing is needed.
14
+ try:
15
+ from .client import EMAYClient
16
+ except ImportError:
17
+ EMAYClient = None # type: ignore
18
+
19
+ __all__ = [
20
+ "Reading",
21
+ "MinuteSample",
22
+ "Status",
23
+ "LiveDownsampler",
24
+ "parse_csv",
25
+ "parse_csv_file",
26
+ "parse_reading",
27
+ "checksum",
28
+ "command",
29
+ "HELLO", "DEVICE_STATE", "START_REALTIME", "STOP_REALTIME",
30
+ "GET_BATTERY", "HEARTBEAT",
31
+ "SERVICE_UUID", "WRITE_UUID", "NOTIFY_UUID",
32
+ ]
33
+ if EMAYClient is not None:
34
+ __all__.append("EMAYClient")
@@ -0,0 +1,244 @@
1
+ """Bleak-based client for the EMAY SleepO2 pulse oximeter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from datetime import datetime, timezone
8
+ from typing import Callable, List, Optional
9
+
10
+ from bleak import BleakClient, BleakScanner
11
+ from bleak.backends.device import BLEDevice
12
+ from bleak.backends.characteristic import BleakGATTCharacteristic
13
+
14
+ from .protocol import (
15
+ SERVICE_UUID, WRITE_UUID, NOTIFY_UUID, NAME_PREFIX,
16
+ HEARTBEAT, START_REALTIME, START_SEQUENCE, STOP_REALTIME,
17
+ parse_reading,
18
+ )
19
+ from .types import Reading, Status, MinuteSample
20
+ from .downsampler import LiveDownsampler
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class EMAYClient:
26
+ """Async BLE client for the EMAY SleepO2.
27
+
28
+ Example::
29
+
30
+ emay = EMAYClient()
31
+ emay.on_reading = lambda r: print(f"SpO2: {r.spo2}% HR: {r.pulse}")
32
+ await emay.start()
33
+ await asyncio.sleep(30)
34
+ await emay.stop()
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ heartbeat_interval: float = 1.5,
40
+ stale_timeout: float = 4.0,
41
+ auto_reconnect: bool = True,
42
+ ):
43
+ self.heartbeat_interval = heartbeat_interval
44
+ self.stale_timeout = stale_timeout
45
+ self.auto_reconnect = auto_reconnect
46
+
47
+ # Callbacks
48
+ self.on_reading: Optional[Callable[[Reading], None]] = None
49
+ self.on_status_change: Optional[Callable[[Status], None]] = None
50
+ self.on_minute_samples: Optional[Callable[[List[MinuteSample]], None]] = None
51
+
52
+ # State
53
+ self._status: Status = Status.IDLE
54
+ self._latest_reading: Optional[Reading] = None
55
+ self._last_reading_at: Optional[datetime] = None
56
+ self._client: Optional[BleakClient] = None
57
+ self._device: Optional[BLEDevice] = None
58
+ self._notify_char: Optional[BleakGATTCharacteristic] = None
59
+ self._write_char: Optional[BleakGATTCharacteristic] = None
60
+ self._heartbeat_task: Optional[asyncio.Task] = None
61
+ self._want_scan = False
62
+ self._downsampler = LiveDownsampler()
63
+ self._known_address: Optional[str] = None
64
+
65
+ @property
66
+ def status(self) -> Status:
67
+ return self._status
68
+
69
+ @status.setter
70
+ def status(self, value: Status) -> None:
71
+ if self._status != value:
72
+ self._status = value
73
+ if self.on_status_change:
74
+ self.on_status_change(value)
75
+
76
+ @property
77
+ def latest_reading(self) -> Optional[Reading]:
78
+ return self._latest_reading
79
+
80
+ @property
81
+ def is_streaming(self) -> bool:
82
+ return self._status == Status.STREAMING
83
+
84
+ async def start(self, address: Optional[str] = None) -> None:
85
+ """Start monitoring for the oximeter."""
86
+ if self._status.is_active:
87
+ return
88
+ self._want_scan = True
89
+ self._known_address = address or self._known_address
90
+ self.status = Status.SCANNING
91
+ await self._begin_monitoring()
92
+
93
+ async def stop(self) -> None:
94
+ """Stop streaming and disconnect."""
95
+ self._want_scan = False
96
+ if self._heartbeat_task:
97
+ self._heartbeat_task.cancel()
98
+ self._heartbeat_task = None
99
+ if self._client and self._client.is_connected and self._write_char:
100
+ try:
101
+ await self._client.write_gatt_char(self._write_char, STOP_REALTIME, response=True)
102
+ except Exception:
103
+ pass
104
+ if self._client:
105
+ try:
106
+ await self._client.disconnect()
107
+ except Exception:
108
+ pass
109
+ self._reset_connection_state()
110
+ self.status = Status.IDLE
111
+
112
+ async def _begin_monitoring(self) -> None:
113
+ if self._device:
114
+ await self._connect_to(self._device)
115
+ return
116
+ if self._known_address:
117
+ # Try to find the known device
118
+ device = await BleakScanner.find_device_by_address(self._known_address)
119
+ if device:
120
+ await self._connect_to(device)
121
+ return
122
+ await self._scan()
123
+
124
+ async def _scan(self) -> None:
125
+ self.status = Status.SCANNING
126
+
127
+ def _filter(device: BLEDevice, adv_data) -> bool:
128
+ if SERVICE_UUID.lower() not in [s.lower() for s in adv_data.service_uuids or []]:
129
+ return False
130
+ name = adv_data.local_name or device.name or ""
131
+ return not name or name.startswith(NAME_PREFIX)
132
+
133
+ device = await BleakScanner.find_device_by_filter(_filter, timeout=10)
134
+ if device is None:
135
+ self.status = Status.FAILED
136
+ if self.on_status_change:
137
+ self.on_status_change(Status.FAILED)
138
+ return
139
+ self._known_address = device.address
140
+ await self._connect_to(device)
141
+
142
+ async def _connect_to(self, device: BLEDevice) -> None:
143
+ self._device = device
144
+ self.status = Status.CONNECTING
145
+ client = BleakClient(
146
+ device,
147
+ disconnected_callback=self._on_disconnect,
148
+ )
149
+ try:
150
+ await client.connect()
151
+ except Exception as e:
152
+ self.status = Status.FAILED
153
+ if self.on_status_change:
154
+ self.on_status_change(Status.FAILED)
155
+ logger.error(f"EMAY: connect failed: {e}")
156
+ return
157
+ self._client = client
158
+ await self._start_streaming()
159
+
160
+ async def _start_streaming(self) -> None:
161
+ client = self._client
162
+ assert client is not None
163
+ # Discover and enable
164
+ svc = client.services.get_service(SERVICE_UUID)
165
+ if svc is None:
166
+ self.status = Status.FAILED
167
+ logger.error("EMAY: service not found")
168
+ return
169
+ self._write_char = svc.get_characteristic(WRITE_UUID)
170
+ self._notify_char = svc.get_characteristic(NOTIFY_UUID)
171
+ if self._write_char is None or self._notify_char is None:
172
+ self.status = Status.FAILED
173
+ logger.error("EMAY: characteristics not found")
174
+ return
175
+
176
+ await client.start_notify(self._notify_char, self._on_data)
177
+
178
+ # Serialized start sequence
179
+ for cmd in START_SEQUENCE:
180
+ await client.write_gatt_char(self._write_char, cmd, response=True)
181
+ self.status = Status.STREAMING
182
+ self._start_heartbeat()
183
+
184
+ def _on_data(self, char: BleakGATTCharacteristic, data: bytearray) -> None:
185
+ result = parse_reading(bytes(data))
186
+ if result is None:
187
+ return
188
+ spo2, pulse = result
189
+ reading = Reading(spo2=spo2, pulse=pulse)
190
+ self._latest_reading = reading
191
+ self._last_reading_at = datetime.now(timezone.utc)
192
+ if self.on_reading:
193
+ self.on_reading(reading)
194
+
195
+ minutes = self._downsampler.add(reading)
196
+ if minutes and self.on_minute_samples:
197
+ self.on_minute_samples(minutes)
198
+
199
+ def _on_disconnect(self, client: BleakClient) -> None:
200
+ was_failure = self._status == Status.FAILED
201
+ is_transient = self._want_scan and not was_failure and self.auto_reconnect
202
+ if not is_transient:
203
+ _ = self._downsampler.flush()
204
+ self._reset_connection_state()
205
+ if is_transient:
206
+ asyncio.ensure_future(self._begin_monitoring())
207
+ elif not was_failure:
208
+ self.status = Status.IDLE
209
+
210
+ def _start_heartbeat(self) -> None:
211
+ if self._heartbeat_task:
212
+ self._heartbeat_task.cancel()
213
+ self._heartbeat_task = asyncio.ensure_future(self._heartbeat_loop())
214
+
215
+ async def _heartbeat_loop(self) -> None:
216
+ while True:
217
+ await asyncio.sleep(self.heartbeat_interval)
218
+ if self._status != Status.STREAMING or self._client is None:
219
+ return
220
+ if self._write_char:
221
+ try:
222
+ await self._client.write_gatt_char(self._write_char, HEARTBEAT, response=True)
223
+ except Exception:
224
+ pass
225
+ # Staleness watchdog
226
+ if self._last_reading_at is not None:
227
+ if (datetime.now(timezone.utc) - self._last_reading_at).total_seconds() > self.stale_timeout:
228
+ if self._latest_reading is not None:
229
+ self._latest_reading = None
230
+ self._downsampler.flush()
231
+
232
+ def _reset_connection_state(self) -> None:
233
+ if self._heartbeat_task:
234
+ self._heartbeat_task.cancel()
235
+ self._heartbeat_task = None
236
+ self._client = None
237
+ self._device = None
238
+ self._write_char = None
239
+ self._notify_char = None
240
+ self._latest_reading = None
241
+ self._last_reading_at = None
242
+
243
+
244
+ __all__ = ["EMAYClient"]
@@ -0,0 +1,130 @@
1
+ """CSV parser for EMAY SleepO2 export files.
2
+
3
+ The device's companion app exports sleep sessions as CSV with columns:
4
+ Date,Time,SpO2(%),PR(bpm). No BLE hardware is required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone, timedelta
10
+ from pathlib import Path
11
+ from typing import List, Tuple, Optional
12
+
13
+ from .types import Reading
14
+
15
+
16
+ class DSTFoldCorrector:
17
+ """Restores physical (monotonic) time across DST fall-back transitions.
18
+
19
+ Without correction, the repeated 1–2 AM hour on clocks-back night
20
+ produces duplicate wall-clock timestamps, silently erasing an hour
21
+ of real data.
22
+ """
23
+
24
+ MIN_BACKWARD_JUMP: float = 5.0
25
+ MAX_BACKWARD_JUMP: float = 7200.0 # 2 hours
26
+ FOLD_DURATION: float = 3600.0 # 1 hour
27
+
28
+ def __init__(self, tz: timezone | None = None):
29
+ import time as _time
30
+ # Store seconds offset from UTC for DST transition detection.
31
+ # We use isdst to detect whether the timezone is currently in DST.
32
+ self._tz = tz
33
+ self._offset: float = 0.0
34
+ self._previous: Optional[datetime] = None
35
+
36
+ def corrected(self, parsed: datetime) -> datetime:
37
+ """Feed naively parsed timestamps in file order; returns
38
+ the fold-corrected physical timestamp."""
39
+ if self._previous is None:
40
+ self._previous = parsed
41
+ return parsed
42
+
43
+ # Once naive parse catches up to corrected timeline, wall clock
44
+ # has passed the ambiguous hour — stop compensating.
45
+ if self._offset > 0 and parsed >= self._previous:
46
+ self._offset = 0.0
47
+
48
+ candidate = parsed + timedelta(seconds=self._offset)
49
+ delta = (candidate - self._previous).total_seconds()
50
+
51
+ if (
52
+ delta < -self.MIN_BACKWARD_JUMP
53
+ and delta >= -self.MAX_BACKWARD_JUMP
54
+ and self._tz is not None
55
+ and _clocks_fell_back(self._previous, self._tz)
56
+ ):
57
+ self._offset += self.FOLD_DURATION
58
+ candidate = parsed + timedelta(seconds=self._offset)
59
+
60
+ self._previous = candidate
61
+ return candidate
62
+
63
+
64
+ def _clocks_fell_back(instant: datetime, tz: timezone) -> bool:
65
+ """True only when the timezone transitioned clocks back near instant."""
66
+ try:
67
+ import time as _time
68
+ except ImportError:
69
+ return False
70
+ # Check if instant is in DST and 1 hour earlier is not —
71
+ # this is a heuristic for fall-back transition proximity.
72
+ before = instant - timedelta(hours=1)
73
+ before_dst = before.astimezone(tz).dst() != timedelta(0)
74
+ instant_dst = instant.astimezone(tz).dst() != timedelta(0)
75
+ return before_dst and not instant_dst
76
+
77
+
78
+ def parse_csv(content: str, timezone: timezone | None = None,
79
+ correct_dst_fold: bool = True) -> Tuple[List[Reading], List[str]]:
80
+ """Parse EMAY CSV content.
81
+
82
+ Returns (readings, warnings). Raises ValueError if the file has no
83
+ data rows.
84
+ """
85
+ lines = [line.strip() for line in content.splitlines() if line.strip()]
86
+ if len(lines) <= 1:
87
+ raise ValueError("CSV file contains no data rows")
88
+
89
+ warnings: List[str] = []
90
+ readings: List[Reading] = []
91
+ corrector = DSTFoldCorrector(tz=timezone if correct_dst_fold else None)
92
+
93
+ for i, line in enumerate(lines[1:], start=2):
94
+ fields = [f.strip() for f in line.split(",")]
95
+ if len(fields) < 2:
96
+ warnings.append(f"Row {i}: skipping — expected at least date,time columns")
97
+ continue
98
+
99
+ date_str = f"{fields[0]} {fields[1]}"
100
+ try:
101
+ parsed = datetime.strptime(date_str, "%m/%d/%Y %I:%M:%S %p")
102
+ except ValueError:
103
+ warnings.append(f"Row {i}: skipping — invalid date/time '{date_str}'")
104
+ continue
105
+
106
+ # Attach local timezone info if provided
107
+ if timezone is not None:
108
+ parsed = parsed.replace(tzinfo=timezone)
109
+
110
+ timestamp = corrector.corrected(parsed) if correct_dst_fold else parsed
111
+
112
+ spo2_str = fields[2] if len(fields) > 2 else ""
113
+ pr_str = fields[3] if len(fields) > 3 else ""
114
+
115
+ spo2 = int(spo2_str) if spo2_str else None
116
+ pulse = int(pr_str) if pr_str else None
117
+
118
+ readings.append(Reading(spo2=spo2, pulse=pulse, timestamp=timestamp))
119
+
120
+ return readings, warnings
121
+
122
+
123
+ def parse_csv_file(path: str | Path, timezone: timezone | None = None,
124
+ correct_dst_fold: bool = True) -> Tuple[List[Reading], List[str]]:
125
+ """Parse an EMAY CSV file from disk."""
126
+ content = Path(path).read_text(encoding="utf-8")
127
+ return parse_csv(content, timezone=timezone, correct_dst_fold=correct_dst_fold)
128
+
129
+
130
+ __all__ = ["parse_csv", "parse_csv_file", "DSTFoldCorrector"]
@@ -0,0 +1,81 @@
1
+ """Per-minute downsampler for the ~1 Hz EMAY stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from datetime import datetime, timedelta
7
+ from typing import Callable, List, Optional
8
+
9
+ from .types import MinuteSample, Reading
10
+
11
+
12
+ def _start_of_minute(dt: datetime) -> datetime:
13
+ return dt.replace(second=0, microsecond=0)
14
+
15
+
16
+ class LiveDownsampler:
17
+ """Buffers ~1 Hz EMAY readings into per-minute mean samples.
18
+
19
+ Thread-safe. Use in a BLE callback to accumulate readings; finalized
20
+ minutes are returned from add().
21
+ """
22
+
23
+ def __init__(self, minimum_samples_per_minute: int = 2):
24
+ self.minimum_samples_per_minute = minimum_samples_per_minute
25
+ self._lock = threading.Lock()
26
+ self._spo2_values: List[float] = []
27
+ self._pulse_values: List[float] = []
28
+ self._current_minute: Optional[datetime] = None
29
+
30
+ def add(self, reading: Reading) -> List[MinuteSample]:
31
+ """Feed a new reading into the current minute bucket.
32
+
33
+ Returns any finalized MinuteSamples (typically 0 or 2 per call).
34
+ """
35
+ with self._lock:
36
+ minute = _start_of_minute(reading.timestamp)
37
+ flushed: List[MinuteSample] = []
38
+
39
+ if self._current_minute is not None and minute != self._current_minute:
40
+ flushed = self._finalize_locked()
41
+
42
+ self._current_minute = minute
43
+ if reading.spo2 is not None:
44
+ self._spo2_values.append(float(reading.spo2))
45
+ if reading.pulse is not None:
46
+ self._pulse_values.append(float(reading.pulse))
47
+ return flushed
48
+
49
+ def flush(self) -> List[MinuteSample]:
50
+ """Finalize and return the current partial bucket."""
51
+ with self._lock:
52
+ return self._finalize_locked()
53
+
54
+ def _finalize_locked(self) -> List[MinuteSample]:
55
+ if self._current_minute is None:
56
+ return []
57
+ minute = self._current_minute
58
+ spo2_vals = self._spo2_values
59
+ pulse_vals = self._pulse_values
60
+ self._spo2_values = []
61
+ self._pulse_values = []
62
+ self._current_minute = None
63
+
64
+ samples: List[MinuteSample] = []
65
+ if len(spo2_vals) >= self.minimum_samples_per_minute:
66
+ mean = sum(spo2_vals) / len(spo2_vals)
67
+ samples.append(MinuteSample(
68
+ minute_start=minute,
69
+ metric_type="SpO2",
70
+ value=mean / 100.0,
71
+ unit_string="%"
72
+ ))
73
+ if len(pulse_vals) >= self.minimum_samples_per_minute:
74
+ mean = sum(pulse_vals) / len(pulse_vals)
75
+ samples.append(MinuteSample(
76
+ minute_start=minute,
77
+ metric_type="PulseRate",
78
+ value=mean,
79
+ unit_string="count/min"
80
+ ))
81
+ return samples
@@ -0,0 +1,87 @@
1
+ """Pure-protocol layer for the EMAY SleepO2 BLE protocol.
2
+
3
+ Contains no Bluetooth calls, no async, no platform dependencies. Each
4
+ language binding reimplements these functions with that language's
5
+ byte-manipulation idioms.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List, Optional, Tuple
11
+
12
+ # ---- BLE identifiers ----
13
+ SERVICE_UUID = "0000ff12-0000-1000-8000-00805f9b34fb"
14
+ WRITE_UUID = "0000ff01-0000-1000-8000-00805f9b34fb"
15
+ NOTIFY_UUID = "0000ff02-0000-1000-8000-00805f9b34fb"
16
+ NAME_PREFIX = "SleepO2"
17
+
18
+ # ---- Commands ----
19
+ HELLO: bytes = bytes([0x89, 0x09])
20
+ DEVICE_STATE: bytes = bytes([0x8E, 0x05, 0x13])
21
+ START_REALTIME: bytes = bytes([0x9B, 0x01, 0x1C])
22
+ STOP_REALTIME: bytes = bytes([0x9B, 0x7F, 0x1A])
23
+ GET_BATTERY: bytes = bytes([0x86, 0x06])
24
+ HEARTBEAT: bytes = bytes([0x9A, 0x1A])
25
+
26
+ START_SEQUENCE: List[bytes] = [HELLO, DEVICE_STATE, START_REALTIME, GET_BATTERY]
27
+
28
+ # ---- Data frame constants ----
29
+ FRAME_LENGTH: int = 8
30
+ FRAME_HEADER: bytes = bytes([0xEB, 0x01, 0x05])
31
+ FRAME_TRAILER: bytes = bytes([0x7F, 0x00])
32
+
33
+ # ---- Plausibility bounds ----
34
+ PULSE_MIN_BPM: int = 30
35
+ PULSE_MAX_BPM: int = 220
36
+ SPO2_MAX_PERCENT: int = 100
37
+ SPO2_MIN_PERCENT: int = 0
38
+
39
+ # ---- Sentinel values ----
40
+ _SENTINEL_VALUES = {0x00, 0xFF}
41
+
42
+
43
+ def checksum(payload: bytes) -> int:
44
+ """Compute the EMAY checksum: sum(payload) & 0x7F.
45
+
46
+ The 0x7F mask (NOT 0xFF) is crucial — 0xFF produces invalid
47
+ checksums that the device silently ignores.
48
+ """
49
+ return sum(payload) & 0x7F
50
+
51
+
52
+ def command(payload: bytes) -> bytes:
53
+ """Build a full command frame: payload + checksum."""
54
+ return payload + bytes([checksum(payload)])
55
+
56
+
57
+ def parse_reading(raw: bytes) -> Optional[Tuple[Optional[int], Optional[int]]]:
58
+ """Attempt to parse an 8-byte raw frame from the BLE notify characteristic.
59
+
60
+ Returns (spo2, pulse) as a tuple on success, or None if the frame
61
+ fails any validation check. Both spo2 and pulse can individually be
62
+ None when the sensor reports "no finger detected" for that metric.
63
+ """
64
+ if len(raw) != FRAME_LENGTH:
65
+ return None
66
+ if raw[0] != FRAME_HEADER[0] or raw[1] != FRAME_HEADER[1] or raw[2] != FRAME_HEADER[2]:
67
+ return None
68
+ if raw[5] != FRAME_TRAILER[0] or raw[6] != FRAME_TRAILER[1]:
69
+ return None
70
+
71
+ cks = sum(raw[:7]) & 0x7F
72
+ if raw[7] != cks:
73
+ return None
74
+
75
+ raw_pr = raw[3]
76
+ raw_spo2 = raw[4]
77
+
78
+ pr: Optional[int] = None if raw_pr in _SENTINEL_VALUES else raw_pr
79
+ spo2: Optional[int] = None if raw_spo2 in _SENTINEL_VALUES else raw_spo2
80
+
81
+ # Application-level plausibility bounds
82
+ if pr is not None and (pr < PULSE_MIN_BPM or pr > PULSE_MAX_BPM):
83
+ return None
84
+ if spo2 is not None and (spo2 < SPO2_MIN_PERCENT or spo2 > SPO2_MAX_PERCENT):
85
+ return None
86
+
87
+ return (spo2, pr)
@@ -0,0 +1,64 @@
1
+ """Data types for the EMAY SleepO2 SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from enum import Enum, auto
8
+ from typing import Optional
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Reading:
13
+ """A single physiological reading from the EMAY SleepO2.
14
+
15
+ Both spo2 and pulse are optional — the device can report a valid
16
+ pulse rate without SpO₂ (finger partially on) or vice versa. None
17
+ means the sensor couldn't acquire that measurement, NOT zero
18
+ saturation or asystole.
19
+ """
20
+
21
+ spo2: Optional[int] # Oxygen saturation percent (0–100)
22
+ pulse: Optional[int] # Pulse rate in bpm
23
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
24
+
25
+ def __repr__(self) -> str:
26
+ s = f"{self.spo2}%" if self.spo2 is not None else "—"
27
+ p = f"{self.pulse}" if self.pulse is not None else "—"
28
+ return f"Reading(spo2={s}, pulse={p})"
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class MinuteSample:
33
+ """A finalized per-minute mean sample."""
34
+
35
+ minute_start: datetime
36
+ metric_type: str # "SpO2" or "PulseRate"
37
+ value: float
38
+ unit_string: str # "%" or "count/min"
39
+
40
+
41
+ class Status(Enum):
42
+ """Observable connection/streaming state."""
43
+
44
+ IDLE = auto()
45
+ SCANNING = auto()
46
+ CONNECTING = auto()
47
+ STREAMING = auto()
48
+ BLUETOOTH_OFF = auto()
49
+ BLUETOOTH_UNAUTHORIZED = auto()
50
+ BLUETOOTH_UNSUPPORTED = auto()
51
+ FAILED = auto()
52
+
53
+ @property
54
+ def is_active(self) -> bool:
55
+ """Whether a session is actively in progress."""
56
+ return self in (Status.SCANNING, Status.CONNECTING, Status.STREAMING)
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class StatusEvent:
61
+ """A status change event from the client."""
62
+
63
+ status: Status
64
+ message: str = ""
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: emay-sleepo2
3
+ Version: 1.0.1
4
+ Summary: BLE client and CSV parser for the EMAY SleepO2 pulse oximeter
5
+ Author: Ground Effect Software, LLC
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/chenders/emay-sleepo2
8
+ Project-URL: Repository, https://github.com/chenders/emay-sleepo2
9
+ Project-URL: Issues, https://github.com/chenders/emay-sleepo2/issues
10
+ Project-URL: Specification, https://github.com/chenders/emay-sleepo2/blob/main/spec.md
11
+ Keywords: emay,sleepo2,pulse-oximeter,spo2,ble,bluetooth,health,medical,oximetry,wearable
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Healthcare Industry
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ Provides-Extra: ble
28
+ Requires-Dist: bleak>=0.22; extra == "ble"
29
+ Provides-Extra: csv
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest; extra == "dev"
32
+ Requires-Dist: ruff; extra == "dev"
33
+ Provides-Extra: all
34
+ Requires-Dist: bleak>=0.22; extra == "all"
35
+ Requires-Dist: pytest; extra == "all"
36
+ Requires-Dist: ruff; extra == "all"
@@ -0,0 +1,15 @@
1
+ pyproject.toml
2
+ src/emay_sleepo2/__init__.py
3
+ src/emay_sleepo2/client.py
4
+ src/emay_sleepo2/csv_parser.py
5
+ src/emay_sleepo2/downsampler.py
6
+ src/emay_sleepo2/protocol.py
7
+ src/emay_sleepo2/types.py
8
+ src/emay_sleepo2.egg-info/PKG-INFO
9
+ src/emay_sleepo2.egg-info/SOURCES.txt
10
+ src/emay_sleepo2.egg-info/dependency_links.txt
11
+ src/emay_sleepo2.egg-info/requires.txt
12
+ src/emay_sleepo2.egg-info/top_level.txt
13
+ tests/test_csv.py
14
+ tests/test_downsampler.py
15
+ tests/test_protocol.py
@@ -0,0 +1,14 @@
1
+
2
+ [all]
3
+ bleak>=0.22
4
+ pytest
5
+ ruff
6
+
7
+ [ble]
8
+ bleak>=0.22
9
+
10
+ [csv]
11
+
12
+ [dev]
13
+ pytest
14
+ ruff
@@ -0,0 +1 @@
1
+ emay_sleepo2
@@ -0,0 +1,40 @@
1
+ """Tests for EMAY CSV parser."""
2
+ import pytest
3
+ from emay_sleepo2.csv_parser import parse_csv, DSTFoldCorrector
4
+ from datetime import datetime, timezone, timedelta
5
+
6
+
7
+ def test_parse_valid():
8
+ csv = "Date,Time,SpO2(%),PR(bpm)\n5/8/2026,4:46:58 PM,98,52\n5/8/2026,4:47:00 PM,,58"
9
+ readings, warnings = parse_csv(csv, correct_dst_fold=False)
10
+ assert len(readings) == 2
11
+ assert readings[0].spo2 == 98
12
+ assert readings[0].pulse == 52
13
+ assert readings[1].spo2 is None
14
+ assert readings[1].pulse == 58
15
+
16
+
17
+ def test_empty_throws():
18
+ with pytest.raises(ValueError):
19
+ parse_csv("Date,Time,SpO2(%),PR(bpm)")
20
+
21
+
22
+ def test_invalid_date_warns():
23
+ csv = "Date,Time,SpO2(%),PR(bpm)\nbad,data,99,50\n5/8/2026,4:47:00 PM,98,52"
24
+ readings, warnings = parse_csv(csv, correct_dst_fold=False)
25
+ assert len(warnings) >= 1
26
+ assert len(readings) == 1
27
+ assert readings[0].spo2 == 98
28
+
29
+
30
+ def test_dst_fold_no_correction_without_transition():
31
+ """Without a real DST transition, backward jumps are left alone."""
32
+ # Fixed-offset timezone has no DST transitions
33
+ utc = timezone.utc
34
+ corrector = DSTFoldCorrector(tz=utc)
35
+ t1 = datetime(2026, 11, 1, 1, 55, tzinfo=utc)
36
+ t2 = datetime(2026, 11, 1, 1, 5, tzinfo=utc)
37
+ c1 = corrector.corrected(t1)
38
+ c2 = corrector.corrected(t2)
39
+ # UTC has no DST → backward jump stays
40
+ assert c2 < c1, "Without DST transition, backward jump should NOT be corrected"
@@ -0,0 +1,76 @@
1
+ """Tests for EMAY downsampler."""
2
+ from datetime import datetime, timedelta
3
+ from emay_sleepo2.downsampler import LiveDownsampler
4
+ from emay_sleepo2.types import Reading
5
+
6
+
7
+ def _reading(spo2, pulse, minute=10, second=30):
8
+ ts = datetime(2026, 5, 8, 16, minute, second)
9
+ return Reading(spo2=spo2, pulse=pulse, timestamp=ts)
10
+
11
+
12
+ def test_below_minimum():
13
+ ds = LiveDownsampler(minimum_samples_per_minute=2)
14
+ result = ds.add(_reading(98, 60))
15
+ assert len(result) == 0
16
+
17
+
18
+ def test_two_samples():
19
+ ds = LiveDownsampler(minimum_samples_per_minute=2)
20
+ ds.add(_reading(98, 60, second=30))
21
+ ds.add(_reading(96, 62, second=31))
22
+ result = ds.flush()
23
+ spo2 = [s for s in result if s.metric_type == "SpO2"][0]
24
+ pulse = [s for s in result if s.metric_type == "PulseRate"][0]
25
+ assert spo2.value == 0.97
26
+ assert pulse.value == 61.0
27
+
28
+
29
+ def test_minute_boundary():
30
+ ds = LiveDownsampler(minimum_samples_per_minute=1)
31
+ ds.add(_reading(98, None, minute=10, second=30))
32
+ flushed = ds.add(_reading(95, 60, minute=11, second=1))
33
+ assert len(flushed) == 1
34
+ assert flushed[0].metric_type == "SpO2"
35
+ assert flushed[0].value == 0.98
36
+
37
+
38
+ def test_boundary_below_minimum():
39
+ ds = LiveDownsampler(minimum_samples_per_minute=5)
40
+ ds.add(_reading(98, 60, minute=10, second=30))
41
+ flushed = ds.add(_reading(95, 62, minute=11, second=1))
42
+ assert len(flushed) == 0
43
+
44
+
45
+ def test_nil_metrics_excluded():
46
+ ds = LiveDownsampler(minimum_samples_per_minute=2)
47
+ ds.add(_reading(98, None, second=30))
48
+ ds.add(_reading(96, 60, second=31))
49
+ result = ds.flush()
50
+ pulse_samples = [s for s in result if s.metric_type == "PulseRate"]
51
+ assert len(pulse_samples) == 0
52
+
53
+
54
+ def test_flush_empties():
55
+ ds = LiveDownsampler(minimum_samples_per_minute=1)
56
+ ds.add(_reading(98, 60))
57
+ assert len(ds.flush()) > 0
58
+ assert len(ds.flush()) == 0
59
+
60
+
61
+ def test_spo2_fraction():
62
+ ds = LiveDownsampler(minimum_samples_per_minute=1)
63
+ ds.add(_reading(50, 60))
64
+ result = ds.flush()
65
+ spo2 = [s for s in result if s.metric_type == "SpO2"][0]
66
+ assert spo2.value == 0.50
67
+ assert spo2.unit_string == "%"
68
+
69
+
70
+ def test_pulse_unit():
71
+ ds = LiveDownsampler(minimum_samples_per_minute=1)
72
+ ds.add(_reading(98, 75))
73
+ result = ds.flush()
74
+ pulse = [s for s in result if s.metric_type == "PulseRate"][0]
75
+ assert pulse.value == 75.0
76
+ assert pulse.unit_string == "count/min"
@@ -0,0 +1,99 @@
1
+ """Tests for EMAY SleepO2 protocol."""
2
+ import pytest
3
+ from emay_sleepo2.protocol import parse_reading, checksum, command
4
+ from emay_sleepo2.types import Reading
5
+
6
+
7
+ def _frame(pr: int, spo2: int) -> bytes:
8
+ """Build a valid frame with given PR and SpO2."""
9
+ raw = bytearray([0xEB, 0x01, 0x05, pr, spo2, 0x7F, 0x00, 0x00])
10
+ raw[7] = sum(raw[:7]) & 0x7F
11
+ return bytes(raw)
12
+
13
+
14
+ class TestChecksum:
15
+ def test_hello(self):
16
+ assert checksum(bytes([0x89])) == 0x09
17
+
18
+ def test_heartbeat(self):
19
+ assert checksum(bytes([0x9A])) == 0x1A
20
+
21
+ def test_start_realtime(self):
22
+ assert checksum(bytes([0x9B, 0x01])) == 0x1C
23
+
24
+ def test_device_state(self):
25
+ assert checksum(bytes([0x8E, 0x05])) == 0x13
26
+
27
+ def test_stop_realtime(self):
28
+ assert checksum(bytes([0x9B, 0x7F])) == 0x1A
29
+
30
+
31
+ class TestParseReading:
32
+ def test_valid(self):
33
+ spo2, pr = parse_reading(_frame(62, 98))
34
+ assert pr == 62
35
+ assert spo2 == 98
36
+
37
+ def test_wrong_length(self):
38
+ assert parse_reading(b'\xEB\x01\x05\x3E\x62\x7F') is None
39
+
40
+ def test_bad_header(self):
41
+ raw = bytearray([0x00, 0x01, 0x05, 62, 98, 0x7F, 0x00, 0x00])
42
+ raw[7] = sum(raw[:7]) & 0x7F
43
+ assert parse_reading(bytes(raw)) is None
44
+
45
+ def test_bad_trailer(self):
46
+ raw = bytearray([0xEB, 0x01, 0x05, 62, 98, 0x00, 0x00, 0x00])
47
+ raw[7] = sum(raw[:7]) & 0x7F
48
+ assert parse_reading(bytes(raw)) is None
49
+
50
+ def test_bad_checksum(self):
51
+ assert parse_reading(bytes([0xEB, 0x01, 0x05, 62, 98, 0x7F, 0x00, 0xFF])) is None
52
+
53
+ def test_sentinel_pr_0(self):
54
+ spo2, pr = parse_reading(_frame(0x00, 98))
55
+ assert pr is None
56
+ assert spo2 == 98
57
+
58
+ def test_sentinel_pr_ff(self):
59
+ spo2, pr = parse_reading(_frame(0xFF, 98))
60
+ assert pr is None
61
+
62
+ def test_sentinel_spo2_0(self):
63
+ spo2, pr = parse_reading(_frame(62, 0x00))
64
+ assert spo2 is None
65
+ assert pr == 62
66
+
67
+ def test_sentinel_spo2_ff(self):
68
+ spo2, pr = parse_reading(_frame(62, 0xFF))
69
+ assert spo2 is None
70
+
71
+ def test_both_sentinels(self):
72
+ spo2, pr = parse_reading(_frame(0xFF, 0xFF))
73
+ assert spo2 is None
74
+ assert pr is None
75
+
76
+ def test_pulse_too_low(self):
77
+ assert parse_reading(_frame(29, 98)) is None
78
+
79
+ def test_pulse_too_high(self):
80
+ assert parse_reading(_frame(221, 98)) is None
81
+
82
+ def test_pulse_boundary_min(self):
83
+ _, pr = parse_reading(_frame(30, 98))
84
+ assert pr == 30
85
+
86
+ def test_pulse_boundary_max(self):
87
+ _, pr = parse_reading(_frame(220, 98))
88
+ assert pr == 220
89
+
90
+ def test_spo2_too_high(self):
91
+ assert parse_reading(_frame(62, 101)) is None
92
+
93
+ def test_spo2_boundary_min(self):
94
+ spo2, _ = parse_reading(_frame(62, 1))
95
+ assert spo2 == 1
96
+
97
+ def test_spo2_boundary_max(self):
98
+ spo2, _ = parse_reading(_frame(62, 100))
99
+ assert spo2 == 100