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.
- emay_sleepo2-1.0.1/PKG-INFO +36 -0
- emay_sleepo2-1.0.1/pyproject.toml +54 -0
- emay_sleepo2-1.0.1/setup.cfg +4 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2/__init__.py +34 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2/client.py +244 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2/csv_parser.py +130 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2/downsampler.py +81 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2/protocol.py +87 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2/types.py +64 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2.egg-info/PKG-INFO +36 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2.egg-info/SOURCES.txt +15 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2.egg-info/dependency_links.txt +1 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2.egg-info/requires.txt +14 -0
- emay_sleepo2-1.0.1/src/emay_sleepo2.egg-info/top_level.txt +1 -0
- emay_sleepo2-1.0.1/tests/test_csv.py +40 -0
- emay_sleepo2-1.0.1/tests/test_downsampler.py +76 -0
- emay_sleepo2-1.0.1/tests/test_protocol.py +99 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|