├── tests ├── __init__.py ├── test_main.py ├── test_battery.py ├── test_real_time_hr.py ├── test_hr_settings.py ├── test_pretty_print.py ├── test_packet.py ├── test_set_time.py ├── test_client.py ├── test_cli.py ├── test_steps.py └── test_hr.py ├── .gitignore ├── colmi_r02_client ├── __init__.py ├── blink_twice.py ├── reboot.py ├── battery.py ├── packet.py ├── pretty_print.py ├── real_time_hr.py ├── hr_settings.py ├── steps.py ├── set_time.py ├── hr.py ├── cli.py └── client.py ├── check.sh ├── docs ├── index.html ├── colmi_r02_client │ ├── blink_twice.html │ └── reboot.html └── colmi_r02_client.html ├── MYSTERIES.md ├── LICENSE ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bin 2 | .hypothesis 3 | dist/ 4 | -------------------------------------------------------------------------------- /colmi_r02_client/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. include:: ../README.md 3 | :start-line: 2 4 | """ 5 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | ruff format 6 | 7 | ruff check --fix 8 | 9 | mypy colmi_r02_client 10 | 11 | pytest 12 | -------------------------------------------------------------------------------- /colmi_r02_client/blink_twice.py: -------------------------------------------------------------------------------- 1 | from colmi_r02_client.packet import make_packet 2 | 3 | CMD_BLINK_TWICE = 16 # 0x10 4 | 5 | BLINK_TWICE_PACKET = make_packet(CMD_BLINK_TWICE) 6 | -------------------------------------------------------------------------------- /colmi_r02_client/reboot.py: -------------------------------------------------------------------------------- 1 | from colmi_r02_client.packet import make_packet 2 | 3 | CMD_REBOOT = 8 # 0x08 4 | 5 | REBOOT_PACKET = make_packet(CMD_REBOOT, bytearray(b"\x01")) 6 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/test_battery.py: -------------------------------------------------------------------------------- 1 | from colmi_r02_client.battery import parse_battery, BatteryInfo 2 | 3 | 4 | def test_parse_battery(): 5 | resp = bytearray(b"\x03@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00C") 6 | expected = BatteryInfo(battery_level=64, charging=False) 7 | 8 | result = parse_battery(resp) 9 | 10 | assert result == expected 11 | -------------------------------------------------------------------------------- /colmi_r02_client/battery.py: -------------------------------------------------------------------------------- 1 | """ 2 | Get the battery level and charging status. 3 | """ 4 | 5 | from dataclasses import dataclass 6 | 7 | from colmi_r02_client.packet import make_packet 8 | 9 | CMD_BATTERY = 3 10 | 11 | BATTERY_PACKET = make_packet(CMD_BATTERY) 12 | 13 | 14 | @dataclass 15 | class BatteryInfo: 16 | battery_level: int 17 | charging: bool 18 | 19 | 20 | def parse_battery(packet: bytearray) -> BatteryInfo: 21 | r""" 22 | example: bytearray(b'\x03@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00C') 23 | """ 24 | return BatteryInfo(battery_level=packet[1], charging=bool(packet[2])) 25 | -------------------------------------------------------------------------------- /tests/test_real_time_hr.py: -------------------------------------------------------------------------------- 1 | from colmi_r02_client.real_time_hr import parse_heart_rate, Reading, ReadingError 2 | 3 | 4 | def test_parse_heart_rate_hr_success(): 5 | packet = bytearray(b"i\x01\x00N\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xb8") 6 | expected = Reading(1, 78) 7 | 8 | result = parse_heart_rate(packet) 9 | 10 | assert result == expected 11 | 12 | 13 | def test_parse_heart_rate_hr_fail(): 14 | packet = bytearray(b"i\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00k") 15 | expected = ReadingError(1, 1) 16 | 17 | result = parse_heart_rate(packet) 18 | 19 | assert result == expected 20 | -------------------------------------------------------------------------------- /MYSTERIES.md: -------------------------------------------------------------------------------- 1 | Some notes on packets and behaviours I don't fully understand yet. 2 | 3 | --- 4 | 5 | I got this one when doing a real time heart rate reading, but I don't think this usually happens 6 | 7 | bytearray(b's\x0c\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x94' 8 | 9 | I got another 's' packet while asking for battery level 10 | 11 | bytearray(b's\x0c+\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xab') 12 | 13 | 14 | This one showed up when asking for steps for a day 15 | 16 | bytearray(b's\x0cd\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe4') 17 | 18 | --- 19 | 20 | Set-time always includes 21 | 22 | bytearray(b'/\xf1\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 ') 23 | 24 | and I don't know what it means 25 | -------------------------------------------------------------------------------- /colmi_r02_client/packet.py: -------------------------------------------------------------------------------- 1 | def make_packet(command: int, sub_data: bytearray | None = None) -> bytearray: 2 | """ 3 | Make a well formed packet from a command key and optional sub data. 4 | 5 | That means ensuring it's 16 bytes long and the last byte is a valid CRC. 6 | 7 | command must be between 0 and 255 (inclusive) 8 | sub_data must have a length between 0 and 14 9 | """ 10 | assert 0 <= command <= 255, "Invalid command, must be between 0 and 255" 11 | packet = bytearray(16) 12 | packet[0] = command 13 | 14 | if sub_data: 15 | assert len(sub_data) <= 14, "Sub data must be less than 14 bytes" 16 | for i in range(len(sub_data)): 17 | packet[i + 1] = sub_data[i] 18 | 19 | packet[-1] = checksum(packet) 20 | 21 | return packet 22 | 23 | 24 | def checksum(packet: bytearray) -> int: 25 | """ 26 | Packet checksum 27 | 28 | Add all the bytes together modulus 255 29 | """ 30 | 31 | return sum(packet) & 255 32 | -------------------------------------------------------------------------------- /tests/test_hr_settings.py: -------------------------------------------------------------------------------- 1 | from hypothesis import given, strategies as st 2 | 3 | from colmi_r02_client.hr_settings import parse_heart_rate_log_settings, HeartRateLogSettings, hr_log_settings_packet 4 | 5 | 6 | def test_parse_enabled(): 7 | resp = bytearray(b"\x16\x01\x01<\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00T") 8 | expected = HeartRateLogSettings(enabled=True, interval=60) 9 | 10 | result = parse_heart_rate_log_settings(resp) 11 | 12 | assert result == expected 13 | 14 | 15 | def test_parse_disabled(): 16 | resp = bytearray(b"\x16\x01\x02<\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00U") 17 | expected = HeartRateLogSettings(enabled=False, interval=60) 18 | 19 | result = parse_heart_rate_log_settings(resp) 20 | 21 | assert result == expected 22 | 23 | 24 | @given(st.booleans(), st.integers(min_value=1, max_value=255)) 25 | def test_hr_settings_packet(enabled, interval): 26 | packet = hr_log_settings_packet(HeartRateLogSettings(enabled, interval)) 27 | assert len(packet) == 16 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Wesley Ellis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /colmi_r02_client/pretty_print.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility class for printing lists of lists, lists of dicts and lists of dataclasses 3 | """ 4 | 5 | from typing import Any 6 | import dataclasses 7 | 8 | 9 | def print_lists(rows: list[list[Any]], header: bool = False) -> str: 10 | widths = [0] * len(rows[0]) 11 | for row in rows: 12 | for i, col in enumerate(row): 13 | widths[i] = max(len(str(col)), widths[i]) 14 | result = [] 15 | for row in rows: 16 | pretty_row = [] 17 | for i, col in enumerate(row): 18 | x = str(col).rjust(widths[i]) 19 | pretty_row.append(x) 20 | result.append(" | ".join(pretty_row)) 21 | 22 | if header: 23 | sep = "-" * len(result[0]) 24 | result.insert(1, sep) 25 | 26 | return "\n".join(result) 27 | 28 | 29 | def print_dicts(rows: list[dict]) -> str: 30 | lists = [list(rows[0].keys())] 31 | lists.extend(list(x.values()) for x in rows) 32 | return print_lists(lists, header=True) 33 | 34 | 35 | def print_dataclasses(dcs: list[Any]) -> str: 36 | dicted = [dataclasses.asdict(d) for d in dcs] 37 | return print_dicts(dicted) 38 | -------------------------------------------------------------------------------- /tests/test_pretty_print.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from colmi_r02_client.pretty_print import print_lists, print_dicts, print_dataclasses 3 | 4 | 5 | @dataclass 6 | class FooBar: 7 | foo: str 8 | bar: int 9 | 10 | 11 | def test_print_lists_simple(): 12 | lists = ["aaa", "b"], ["c", "dd"] 13 | 14 | expected = "aaa | b\n c | dd" 15 | actual = print_lists(lists) 16 | assert actual == expected 17 | 18 | 19 | def test_print_lists_header(): 20 | lists = ["aaa", "b"], ["c", "dd"] 21 | 22 | expected = "aaa | b\n--------\n c | dd" 23 | actual = print_lists(lists, header=True) 24 | assert actual == expected 25 | 26 | 27 | def test_print_dicts(): 28 | dicts = [{"a": 1, "b": 1000}, {"a": 2, "b": 3}] 29 | 30 | expected = "a | b\n--------\n1 | 1000\n2 | 3" 31 | actual = print_dicts(dicts) 32 | assert actual == expected 33 | 34 | 35 | def test_print_dataclasses(): 36 | dcs = [FooBar("a", 1), FooBar("aaaaaa", 10000)] 37 | 38 | expected = " foo | bar\n--------------\n a | 1\naaaaaa | 10000" 39 | actual = print_dataclasses(dcs) 40 | assert actual == expected 41 | 42 | 43 | # TODO add some nice juicy property tests 44 | -------------------------------------------------------------------------------- /tests/test_packet.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hypothesis import given 4 | import hypothesis.strategies as st 5 | 6 | from colmi_r02_client.packet import make_packet, checksum 7 | 8 | 9 | @given(command=st.integers(min_value=0, max_value=255), sub_data=st.binary(max_size=14)) 10 | def test_make_packet_works_on_valid_data(command, sub_data): 11 | packet = make_packet(command, bytearray(sub_data)) 12 | 13 | assert len(packet) == 16 14 | assert packet[-1] == checksum(packet[0:15]) 15 | 16 | 17 | @given(command=st.integers().filter(lambda x: x < 0 or x > 256)) 18 | def test_make_packet_raises_on_bad_command(command): 19 | with pytest.raises(AssertionError): 20 | make_packet(command) 21 | 22 | 23 | @given(st.binary(min_size=15)) 24 | def test_make_packet_raises_on_too_long_sub_data(sub_data): 25 | with pytest.raises(AssertionError): 26 | make_packet(1, bytearray(sub_data)) 27 | 28 | 29 | @given(st.binary(min_size=1, max_size=14)) 30 | def test_make_packet_includes_sub_data(sub_data): 31 | s = bytearray(sub_data) 32 | 33 | p = make_packet(1, s) 34 | 35 | assert p[1 : 1 + len(s)] == s 36 | 37 | 38 | def test_sample_checksum(): 39 | message = bytearray(b"\x15\x00\x18\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00") 40 | assert checksum(message) == 0x32 41 | 42 | 43 | @given(st.binary()) 44 | def test_checksum(message): 45 | assert 0 <= checksum(bytearray(message)) < 256 46 | -------------------------------------------------------------------------------- /colmi_r02_client/real_time_hr.py: -------------------------------------------------------------------------------- 1 | """ 2 | This covers commands for starting and stopping the real time 3 | heart rate and blood oxygen (SPO2) measurements, and parsing the results 4 | """ 5 | 6 | from dataclasses import dataclass 7 | 8 | from colmi_r02_client.packet import make_packet 9 | 10 | CMD_REAL_TIME_HEART_RATE = 30 # 0x1E 11 | CMD_START_HEART_RATE = 105 # 0x69 12 | CMD_STOP_HEART_RATE = 106 # 0x6A 13 | 14 | 15 | START_HEART_RATE_PACKET = make_packet( 16 | CMD_START_HEART_RATE, 17 | bytearray(b"\x01\x00"), 18 | ) # why is this backwards? 19 | CONTINUE_HEART_RATE_PACKET = make_packet(CMD_REAL_TIME_HEART_RATE, bytearray(b"3")) 20 | STOP_HEART_RATE_PACKET = make_packet(CMD_STOP_HEART_RATE, bytearray(b"\x01\x00\x00")) 21 | 22 | START_SPO2_PACKET = make_packet(CMD_START_HEART_RATE, bytearray(b"\x03\x25")) 23 | STOP_SPO2_PACKET = make_packet(CMD_STOP_HEART_RATE, bytearray(b"\x03\x00\x00")) 24 | 25 | 26 | @dataclass 27 | class Reading: 28 | kind: int 29 | """ 30 | either heart rate or spo2 31 | 32 | TODO make this an enum and figure out which is which 33 | """ 34 | 35 | value: int 36 | 37 | 38 | @dataclass 39 | class ReadingError: 40 | code: int 41 | kind: int 42 | 43 | 44 | def parse_heart_rate(packet: bytearray) -> Reading | ReadingError: 45 | """Parses the heart rate and spo2 packets""" 46 | 47 | assert packet[0] == CMD_START_HEART_RATE 48 | 49 | kind = packet[1] 50 | error_code = packet[2] 51 | if error_code != 0: 52 | return ReadingError(kind=kind, code=error_code) 53 | 54 | return Reading(kind=packet[1], value=packet[3]) 55 | -------------------------------------------------------------------------------- /tests/test_set_time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone, timedelta 2 | 3 | from colmi_r02_client.set_time import byte_to_bcd, set_time_packet, CMD_SET_TIME, parse_set_time_packet 4 | 5 | import pytest 6 | 7 | 8 | @pytest.mark.parametrize(("normal", "bcd"), [(0, 0), (10, 0b00010000), (99, 0b10011001)]) 9 | def test_byte_to_bcd(normal, bcd): 10 | assert bcd == byte_to_bcd(normal) 11 | 12 | 13 | @pytest.mark.parametrize("bad", [-1, 100, 1000]) 14 | def test_byte_to_bcd_bad(bad): 15 | with pytest.raises(AssertionError): 16 | byte_to_bcd(bad) 17 | 18 | 19 | def test_set_time_packet(): 20 | ts = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) 21 | expected = bytearray(b"\x01$\x01\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00(") 22 | 23 | actual = set_time_packet(ts) 24 | 25 | assert actual == expected 26 | 27 | assert actual[0] == CMD_SET_TIME 28 | 29 | 30 | def test_set_time_1999(): 31 | ts = datetime(1999, 1, 1, 0, 0, 0) 32 | with pytest.raises(AssertionError): 33 | set_time_packet(ts) 34 | 35 | 36 | def test_set_time_with_timezone(): 37 | ts = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=-4))) 38 | expected = bytearray(b"\x01$\x01\x01\x04\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00,") 39 | 40 | actual = set_time_packet(ts) 41 | 42 | assert actual == expected 43 | 44 | 45 | def test_parse_set_time_response(): 46 | packet = bytearray(b'\x01\x00\x01\x00"\x00\x00\x00\x00\x01\x000\x01\x00\x10f') 47 | 48 | capabilities = parse_set_time_packet(packet) 49 | 50 | assert capabilities["mSupportManualHeart"] 51 | assert not capabilities["mSupportBloodSugar"] 52 | -------------------------------------------------------------------------------- /colmi_r02_client/hr_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Heart rate log settings for controlling if the ring should record heart rate periodically, and if so how often to record. 3 | 4 | An odd packet set up as it's either a query for the current settings or trying to set the settings. 5 | 6 | I don't know what byte 1 in the response is. 7 | """ 8 | 9 | from dataclasses import dataclass 10 | import logging 11 | 12 | from colmi_r02_client.packet import make_packet 13 | 14 | CMD_HEART_RATE_LOG_SETTINGS = 22 # 0x16 15 | 16 | READ_HEART_RATE_LOG_SETTINGS_PACKET = make_packet(CMD_HEART_RATE_LOG_SETTINGS, bytearray(b"\x01")) 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | @dataclass 22 | class HeartRateLogSettings: 23 | enabled: bool 24 | interval: int 25 | """Interval in minutes""" 26 | 27 | 28 | def parse_heart_rate_log_settings(packet: bytearray) -> HeartRateLogSettings: 29 | r""" 30 | example: bytearray(b'\x16\x01\x01<\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00T') 31 | """ 32 | assert packet[0] == CMD_HEART_RATE_LOG_SETTINGS 33 | 34 | raw_enabled = packet[2] 35 | if raw_enabled == 1: 36 | enabled = True 37 | elif raw_enabled == 2: 38 | enabled = False 39 | else: 40 | logger.warning(f"Unexpected value in enabled byte {raw_enabled}, defaulting to false") 41 | enabled = False 42 | 43 | return HeartRateLogSettings(enabled=enabled, interval=packet[3]) 44 | 45 | 46 | def hr_log_settings_packet(settings: HeartRateLogSettings) -> bytearray: 47 | assert 0 < settings.interval < 256, "Interval must be between 0 and 255" 48 | enabled = 1 if settings.enabled else 2 49 | sub_data = bytearray([2, enabled, settings.interval]) 50 | return make_packet(CMD_HEART_RATE_LOG_SETTINGS, sub_data) 51 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from unittest.mock import Mock 3 | 4 | from bleak.backends.characteristic import BleakGATTCharacteristic 5 | from hypothesis import given 6 | import hypothesis.strategies as st 7 | import pytest 8 | 9 | 10 | from colmi_r02_client.client import Client 11 | from colmi_r02_client import battery 12 | 13 | MOCK_CHAR = Mock(spec=BleakGATTCharacteristic) 14 | 15 | 16 | @given(st.binary().filter(lambda x: len(x) != 16)) 17 | def test_handle_tx_short_packet(raw): 18 | client = Client("unused") 19 | 20 | with pytest.raises(AssertionError, match="Packet is the wrong length"): 21 | client._handle_tx(MOCK_CHAR, bytearray(raw)) 22 | 23 | 24 | @given(st.binary(min_size=16, max_size=16).filter(lambda x: x[0] >= 127)) 25 | def test_handle_tx_error_bit(raw): 26 | client = Client("unused") 27 | 28 | with pytest.raises(AssertionError, match="Packet has error bit"): 29 | client._handle_tx(MOCK_CHAR, bytearray(raw)) 30 | 31 | 32 | async def test_handle_tx_real_packet(): 33 | client = Client("unused") 34 | packet = bytearray(b"\x03@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00C") 35 | expected = battery.BatteryInfo(64, False) 36 | 37 | client._handle_tx(MOCK_CHAR, packet) 38 | 39 | result = await client.queues[battery.CMD_BATTERY].get() 40 | assert result == expected 41 | 42 | 43 | def test_handle_tx_none_parse(caplog): 44 | caplog.set_level(logging.DEBUG) 45 | client = Client("unused") 46 | # set time packet response is ignored 47 | packet = bytearray(b'\x01\x00\x01\x00"\x00\x00\x00\x00\x01\x000\x01\x00\x10f') 48 | 49 | client._handle_tx(MOCK_CHAR, packet) 50 | 51 | assert "No result returned from parser for 1" in caplog.text 52 | 53 | 54 | def test_handle_tx_unexpected_packet(caplog): 55 | client = Client("unused") 56 | 57 | # 125 is currently unused 58 | packet = bytearray(b"}\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00}") 59 | 60 | client._handle_tx(MOCK_CHAR, packet) 61 | 62 | assert "Did not expect this packet:" in caplog.text 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "colmi-r02-client" 3 | version = "0.1.0" 4 | description = "Connect to Colmi R02 smart ring over BLE" 5 | author = "Wesley Ellis
10 |
11 | It's a cheap (as in $20) "smart ring" / fitness wearable that includes the following sensors:
12 |
13 | - Accelerometer
14 | - step tracking
15 | - sleep tracking
16 | - gestures (maybe...?)
17 | - Heart Rate (HR)
18 | - Blood Oxygen (SPO2)
19 |
20 | I found out about the ring from atc1441 and his work on [ATC_RF03](https://github.com/atc1441/ATC_RF03_Ring/) and the
21 | [Hackaday coverage](https://hackaday.com/2024/06/16/new-part-day-a-hackable-smart-ring/)
22 |
23 | Got questions or ideas?
24 |
25 | - [Send me an email](mailto:tahnok+colmir02@gmail.com)
26 | - [open an issue](https://github.com/tahnok/colmi_r02_client/issues/new)
27 | - [join the discord](https://discord.gg/K4wvDqDZvn)
28 |
29 | Are you hiring? [Send me an email](mailto:tahnok+colmir02@gmail.com)
30 |
31 | ## How to buy
32 |
33 | You can get it on [here on AliExpress](https://www.aliexpress.com/item/1005006631448993.html). If that link is dead try searching for "COLMI R02", I got mine from "Colmi official store". It cost me $CAD 22 shipped.
34 |
35 | The Colmi R06 is also compatible, and the R03 is probably compatible as well. Investigation into the R10 is ongoing, but if you have one please let me know if it works!
36 |
37 | ## Reverse engineering status
38 |
39 | - [x] Real time heart rate and SPO2
40 | - [x] Step logs (still don't quite understand how the day is split up)
41 | - [x] Heart rate logs (aka periodic measurement)
42 | - [x] Set ring time
43 | - [x] Set HR log frequency
44 | - [ ] SPO2 logs
45 | - [ ] Sleep tracking
46 | - [ ] "Stress" measurement
47 |
48 | ## Planned Feature
49 |
50 | - add more CLI functionality
51 | - pretty print HR and steps
52 | - sync all data to a file or SQLite db
53 | - simple web interface
54 |
55 | ## Getting started
56 |
57 | ### Using the command line
58 |
59 | If you don't know python that well, I **highly** recommend you install [pipx](https://pipx.pypa.io/stable/installation/). It's purpose built for managing python packages intended to be used as standalone programs and it will keep your computer safe from the pitfalls of python packaging. Once installed you can do
60 |
61 | ```sh
62 | pipx install git+https://github.com/tahnok/colmi_r02_client
63 | ```
64 |
65 | Once that is done you can look for nearby rings using
66 |
67 | ```sh
68 | colmi_r02_util scan
69 | ```
70 |
71 | ```
72 | Found device(s)
73 | Name | Address
74 | --------------------------------------------
75 | R02_341C | 70:CB:0D:D0:34:1C
76 | ```
77 |
78 | Once you have your address you can use it to do things like get real time heart rate
79 |
80 | ```sh
81 | colmi_r02_client --address=70:CB:0D:D0:34:1C get-real-time-heart-rate
82 | ```
83 |
84 | ```
85 | Starting reading, please wait.
86 | [81, 81, 79, 79, 79, 79]
87 | ```
88 |
89 | The most up to date and comprehensive help for the command line can be found running
90 |
91 | ```sh
92 | colmi_r02_client --help
93 | ```
94 |
95 | ```
96 | Usage: colmi_r02_client [OPTIONS] COMMAND [ARGS]...
97 |
98 | Options:
99 | --debug / --no-debug
100 | --record / --no-record Write all received packets to a file
101 | --address TEXT Bluetooth address
102 | --name TEXT Bluetooth name of the device, slower but will work
103 | on macOS
104 | --help Show this message and exit.
105 |
106 | Commands:
107 | get-heart-rate-log Get heart rate for given date
108 | get-heart-rate-log-settings Get heart rate log settings
109 | get-real-time-heart-rate Get real time heart rate.
110 | info Get device info and battery level
111 | set-heart-rate-log-settings Get heart rate log settings
112 | set-time Set the time on the ring, required if you...
113 | ```
114 |
115 | ### With the library / SDK
116 |
117 | You can use the `colmi_r02_client.client` class as a library to do your own stuff in python. I've tried to write a lot of docstrings, which are visible on [the docs site](https://tahnok.github.io/colmi_r02_client/)
118 |
119 | ## Communication Protocol Details
120 |
121 | I've kept a lab notebook style stream of consciousness notes on https://notes.tahnok.ca/, starting with [2024-07-07 Smart Ring Hacking](https://notes.tahnok.ca/blog/2024-07-07+Smart+Ring+Hacking) and eventually getting put under one folder. That's the best source for all the raw stuff.
122 |
123 | At a high level though, you can talk to and read from the ring using BLE. There's no binding or security keys required to get started. (that's kind of bad, but the range on the ring is really tiny and I'm not too worried about someone getting my steps or heart rate information. Up to you).
124 |
125 | The ring has a BLE GATT service with the UUID `6E40FFF0-B5A3-F393-E0A9-E50E24DCCA9E`. It has two important characteristics:
126 |
127 | 1. RX: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E`, which you write to
128 | 2. TX: `6E400003-B5A3-F393-E0A9-E50E24DCCA9E`, which you can "subscribe" to and is where the ring responds to packets you have sent.
129 |
130 | This closely resembles the [Nordic UART Service](https://docs.nordicsemi.com/bundle/ncs-latest/page/nrf/libraries/bluetooth_services/services/nus.html) and UART/Serial communications in general.
131 |
132 | ### Packet structure
133 |
134 | The ring communicates in 16 byte packets for both sending and receiving. The first byte of the packet is always a command/tag/type. For example, the packet you send to ask for the battery level starts with `0x03` and the response packet also starts with `0x03`.
135 |
136 | The last byte of the packet is always a checksum/crc. This value is calculated by summing up the other 15 bytes in the packet and taking the result modulo 255. See `colmi_r02_client.packet.checksum`
137 |
138 | The middle 14 bytes are the "subdata" or payload data. Some requests (like `colmi_r02_client.set_time.set_time_packet`) include additional data. Almost all responses use the subdata to return the data you asked for.
139 |
140 | Some requests result in multiple responses that you have to consider together to get the data. `colmi_r02_client.steps.SportDetailParser` is an example of this behaviour.
141 |
142 | If you want to know the actual packet structure for a given feature's request or response, take a look at the source code for that feature. I've tried to make it pretty easy to follow even if you don't know python very well. There are also some tests that you can refer to for validated request/response pairs and human readable interpretations of that data.
143 |
144 | Got questions or ideas? [Send me an email](mailto:tahnok+colmir02@gmail.com) or [open an issue](https://github.com/tahnok/colmi_r02_client/issues/new)
145 |
146 | ## Other links
147 |
148 | - https://github.com/Puxtril/colmi-docs
149 | - gadgetbridge (open source android client for many smart devices) support [PR](https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/3896)
150 |
--------------------------------------------------------------------------------
/colmi_r02_client/cli.py:
--------------------------------------------------------------------------------
1 | """
2 | A python client for connecting to the Colmi R02 Smart ring
3 | """
4 |
5 | import csv
6 | import dataclasses
7 | from datetime import datetime, timezone
8 | from io import StringIO
9 | from pathlib import Path
10 | import logging
11 | import time
12 |
13 | import asyncclick as click
14 | from bleak import BleakScanner
15 |
16 | from colmi_r02_client.client import Client
17 | from colmi_r02_client.hr import HeartRateLog
18 | from colmi_r02_client import steps, pretty_print
19 |
20 | logging.basicConfig(level=logging.WARNING, format="%(name)s: %(message)s")
21 |
22 | logger = logging.getLogger(__name__)
23 |
24 |
25 | @click.group()
26 | @click.option("--debug/--no-debug", default=False)
27 | @click.option(
28 | "--record/--no-record",
29 | default=False,
30 | help="Write all received packets to a file",
31 | )
32 | @click.option("--address", required=False, help="Bluetooth address")
33 | @click.option("--name", required=False, help="Bluetooth name of the device, slower but will work on macOS")
34 | @click.pass_context
35 | async def cli_client(context: click.Context, debug: bool, record: bool, address: str | None, name: str | None) -> None:
36 | if (address is None and name is None) or (address is not None and name is not None):
37 | context.fail("You must pass either the address option(preferred) or the name option, but not both")
38 |
39 | if debug:
40 | logging.getLogger().setLevel(logging.DEBUG)
41 | logging.getLogger("bleak").setLevel(logging.INFO)
42 |
43 | record_to = None
44 | if record:
45 | now = int(time.time())
46 | captures = Path("captures")
47 | captures.mkdir(exist_ok=True)
48 | record_to = captures / Path(f"colmi_response_capture_{now}.bin")
49 | logger.info(f"Recording responses to {record_to}")
50 |
51 | if name is not None:
52 | devices = await BleakScanner.discover()
53 | found = next((x for x in devices if x.name == name), None)
54 | if found is None:
55 | context.fail("No device found with given name")
56 | address = found.address
57 |
58 | assert address
59 |
60 | client = Client(address, record_to=record_to)
61 |
62 | context.obj = client
63 |
64 |
65 | @cli_client.command()
66 | @click.pass_obj
67 | async def info(client: Client) -> None:
68 | """Get device info and battery level"""
69 |
70 | async with client:
71 | print("device info", await client.get_device_info())
72 | print("battery:", await client.get_battery())
73 |
74 |
75 | @cli_client.command()
76 | @click.option(
77 | "--target",
78 | type=click.DateTime(),
79 | required=True,
80 | help="The date you want logs for",
81 | )
82 | @click.pass_obj
83 | async def get_heart_rate_log(client: Client, target: datetime) -> None:
84 | """Get heart rate for given date"""
85 |
86 | async with client:
87 | log = await client.get_heart_rate_log(target)
88 | print("Data:", log)
89 | if isinstance(log, HeartRateLog):
90 | for hr, ts in log.heart_rates_with_times():
91 | if hr != 0:
92 | print(f"{ts.strftime('%H:%M')}, {hr}")
93 |
94 |
95 | @cli_client.command()
96 | @click.option(
97 | "--when",
98 | type=click.DateTime(),
99 | required=False,
100 | help="The date and time you want to set the ring to",
101 | )
102 | @click.pass_obj
103 | async def set_time(client: Client, when: datetime | None) -> None:
104 | """
105 | Set the time on the ring, required if you want to be able to interpret any of the logged data
106 | """
107 |
108 | if when is None:
109 | when = datetime.now(tz=timezone.utc)
110 | async with client:
111 | await client.set_time(when)
112 |
113 |
114 | @cli_client.command()
115 | @click.pass_obj
116 | async def get_heart_rate_log_settings(client: Client) -> None:
117 | """Get heart rate log settings"""
118 |
119 | async with client:
120 | click.echo("heart rate log settings:")
121 | click.echo(await client.get_heart_rate_log_settings())
122 |
123 |
124 | @cli_client.command()
125 | @click.option("--enable/--disable", default=True, show_default=True, help="Logging status")
126 | @click.option(
127 | "--interval",
128 | type=click.IntRange(0, 255),
129 | help="Interval in minutes to measure heart rate",
130 | default=60,
131 | show_default=True,
132 | )
133 | @click.pass_obj
134 | async def set_heart_rate_log_settings(client: Client, enable: bool, interval: int) -> None:
135 | """Get heart rate log settings"""
136 |
137 | async with client:
138 | click.echo("Changing heart rate log settings")
139 | await client.set_heart_rate_log_settings(enable, interval)
140 | click.echo(await client.get_heart_rate_log_settings())
141 | click.echo("Done")
142 |
143 |
144 | @cli_client.command()
145 | @click.pass_obj
146 | async def get_real_time_heart_rate(client: Client) -> None:
147 | """Get real time heart rate.
148 |
149 | TODO: add number of readings
150 | """
151 |
152 | async with client:
153 | click.echo("Starting reading, please wait.")
154 | result = await client.get_realtime_heart_rate()
155 | if result:
156 | click.echo(result)
157 | else:
158 | click.echo("Error, no HR detected. Is the ring being worn?")
159 |
160 |
161 | @cli_client.command()
162 | @click.pass_obj
163 | @click.option(
164 | "--when",
165 | type=click.DateTime(),
166 | required=False,
167 | help="The date you want steps for",
168 | )
169 | @click.option("--as-csv", is_flag=True, help="Print as CSV", default=False)
170 | async def get_steps(client: Client, when: datetime | None = None, as_csv: bool = False) -> None:
171 | """Get step data"""
172 |
173 | if when is None:
174 | when = datetime.now(tz=timezone.utc)
175 | async with client:
176 | result = await client.get_steps(when)
177 | if isinstance(result, steps.NoData):
178 | click.echo("No results for day")
179 | return
180 |
181 | if not as_csv:
182 | click.echo(pretty_print.print_dataclasses(result))
183 | else:
184 | out = StringIO()
185 | writer = csv.DictWriter(out, fieldnames=[f.name for f in dataclasses.fields(steps.SportDetail)])
186 | writer.writeheader()
187 | for r in result:
188 | writer.writerow(dataclasses.asdict(r))
189 | click.echo(out.getvalue())
190 |
191 |
192 | @cli_client.command()
193 | @click.pass_obj
194 | async def reboot(client: Client) -> None:
195 | """Reboot the ring"""
196 |
197 | async with client:
198 | await client.reboot()
199 | click.echo("Ring rebooted")
200 |
201 |
202 | @cli_client.command()
203 | @click.pass_obj
204 | @click.option(
205 | "--command",
206 | type=click.IntRange(min=0, max=255),
207 | help="Raw command",
208 | )
209 | @click.option(
210 | "--subdata",
211 | type=str,
212 | help="Hex encoded subdata array, will be parsed into a bytearray",
213 | )
214 | @click.option("--replies", type=click.IntRange(min=0), default=0, help="How many reply packets to wait for")
215 | async def raw(client: Client, command: int, subdata: str | None, replies: int) -> None:
216 | """Send the ring a raw command"""
217 |
218 | p_subdata = bytearray.fromhex(subdata) if subdata is not None else bytearray()
219 |
220 | async with client:
221 | results = await client.raw(command, p_subdata, replies)
222 | click.echo(results)
223 |
224 |
225 | DEVICE_NAME_PREFIXES = [
226 | "R01",
227 | "R02",
228 | "R03",
229 | "R04",
230 | "R05",
231 | "R06",
232 | "R07",
233 | "R10", # maybe compatible?
234 | "VK-5098",
235 | "MERLIN",
236 | "Hello Ring",
237 | "RING1",
238 | "boAtring",
239 | "TR-R02",
240 | "SE",
241 | "EVOLVEO",
242 | "GL-SR2",
243 | "Blaupunkt",
244 | "KSIX RING",
245 | ]
246 |
247 |
248 | @click.group()
249 | async def util():
250 | """Generic utilities for the R02 that don't need an address."""
251 |
252 |
253 | @util.command()
254 | @click.option("--all", is_flag=True, help="Print all devices, no name filtering", default=False)
255 | async def scan(all: bool) -> None:
256 | """Scan for possible devices based on known prefixes and print the bluetooth address."""
257 |
258 | # TODO maybe bluetooth specific stuff like this should be in another package?
259 | devices = await BleakScanner.discover()
260 |
261 | if len(devices) > 0:
262 | click.echo("Found device(s)")
263 | click.echo(f"{'Name':>20} | Address")
264 | click.echo("-" * 44)
265 | for d in devices:
266 | name = d.name
267 | if name and (all or any(name for p in DEVICE_NAME_PREFIXES if name.startswith(p))):
268 | click.echo(f"{name:>20} | {d.address}")
269 | else:
270 | click.echo("No devices found. Try moving the ring closer to computer")
271 |
--------------------------------------------------------------------------------
/colmi_r02_client/client.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from collections.abc import Callable
3 | from datetime import datetime, timezone
4 | import logging
5 | from pathlib import Path
6 | from types import TracebackType
7 | from typing import Any
8 |
9 | from bleak import BleakClient
10 | from bleak.backends.characteristic import BleakGATTCharacteristic
11 |
12 | from colmi_r02_client import (
13 | battery,
14 | real_time_hr,
15 | steps,
16 | set_time,
17 | blink_twice,
18 | hr,
19 | hr_settings,
20 | packet,
21 | reboot,
22 | )
23 |
24 | UART_SERVICE_UUID = "6E40FFF0-B5A3-F393-E0A9-E50E24DCCA9E"
25 | UART_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
26 | UART_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
27 |
28 | DEVICE_INFO_UUID = "0000180A-0000-1000-8000-00805F9B34FB"
29 | DEVICE_HW_UUID = "00002A27-0000-1000-8000-00805F9B34FB"
30 | DEVICE_FW_UUID = "00002A26-0000-1000-8000-00805F9B34FB"
31 |
32 | logger = logging.getLogger(__name__)
33 |
34 |
35 | def empty_parse(_packet: bytearray) -> None:
36 | """Used for commands that we expect a response, but there's nothing in the response"""
37 | return None
38 |
39 |
40 | def log_packet(packet: bytearray) -> None:
41 | print("received: ", packet)
42 |
43 |
44 | COMMAND_HANDLERS: dict[int, Callable[[bytearray], Any]] = {
45 | battery.CMD_BATTERY: battery.parse_battery,
46 | real_time_hr.CMD_START_HEART_RATE: real_time_hr.parse_heart_rate,
47 | real_time_hr.CMD_STOP_HEART_RATE: empty_parse,
48 | steps.CMD_GET_STEP_SOMEDAY: steps.SportDetailParser().parse,
49 | hr.CMD_READ_HEART_RATE: hr.HeartRateLogParser().parse,
50 | set_time.CMD_SET_TIME: empty_parse,
51 | hr_settings.CMD_HEART_RATE_LOG_SETTINGS: hr_settings.parse_heart_rate_log_settings,
52 | }
53 | """
54 | TODO put these somewhere nice
55 |
56 | These are commands that we expect to have a response returned for
57 | they must accept a packet as bytearray and then return a value to be put
58 | in the queue for that command type
59 | NOTE: if the value returned is None, it is not added to the queue, this is to support
60 | multi packet messages where the parser has state
61 | """
62 |
63 |
64 | class Client:
65 | def __init__(self, address: str, record_to: Path | None = None):
66 | self.address = address
67 | self.bleak_client = BleakClient(self.address)
68 | self.queues: dict[int, asyncio.Queue] = {cmd: asyncio.Queue() for cmd in COMMAND_HANDLERS}
69 | self.record_to = record_to
70 |
71 | async def __aenter__(self) -> "Client":
72 | logger.info(f"Connecting to {self.address}")
73 | await self.connect()
74 | logger.info("Connected!")
75 | return self
76 |
77 | async def __aexit__(
78 | self,
79 | exc_type: type[BaseException] | None,
80 | exc_val: BaseException | None,
81 | exc_tb: TracebackType | None,
82 | ) -> None:
83 | await self.disconnect()
84 |
85 | async def connect(self):
86 | await self.bleak_client.connect()
87 |
88 | nrf_uart_service = self.bleak_client.services.get_service(UART_SERVICE_UUID)
89 | assert nrf_uart_service
90 | rx_char = nrf_uart_service.get_characteristic(UART_RX_CHAR_UUID)
91 | assert rx_char
92 | self.rx_char = rx_char
93 |
94 | await self.bleak_client.start_notify(UART_TX_CHAR_UUID, self._handle_tx)
95 |
96 | async def disconnect(self):
97 | await self.bleak_client.disconnect()
98 |
99 | def _handle_tx(self, _: BleakGATTCharacteristic, packet: bytearray) -> None:
100 | """Bleak callback that handles new packets from the ring."""
101 |
102 | logger.info(f"Received packet {packet}")
103 |
104 | assert len(packet) == 16, f"Packet is the wrong length {packet}"
105 | packet_type = packet[0]
106 | assert packet_type < 127, f"Packet has error bit set {packet}"
107 |
108 | if packet_type in COMMAND_HANDLERS:
109 | result = COMMAND_HANDLERS[packet_type](packet)
110 | if result is not None:
111 | self.queues[packet_type].put_nowait(result)
112 | else:
113 | logger.debug(f"No result returned from parser for {packet_type}")
114 | else:
115 | logger.warning(f"Did not expect this packet: {packet}")
116 |
117 | if self.record_to is not None:
118 | with self.record_to.open("ab") as f:
119 | f.write(packet)
120 | f.write(b"\n")
121 |
122 | async def send_packet(self, packet: bytearray) -> None:
123 | logger.debug(f"Sending packet: {packet}")
124 | await self.bleak_client.write_gatt_char(self.rx_char, packet, response=False)
125 |
126 | async def get_battery(self) -> battery.BatteryInfo:
127 | await self.send_packet(battery.BATTERY_PACKET)
128 | result = await self.queues[battery.CMD_BATTERY].get()
129 | assert isinstance(result, battery.BatteryInfo)
130 | return result
131 |
132 | async def get_realtime_heart_rate(self) -> list[int] | None:
133 | return await self._poll_real_time_reading(real_time_hr.START_HEART_RATE_PACKET)
134 |
135 | async def _poll_real_time_reading(self, start_packet: bytearray) -> list[int] | None:
136 | await self.send_packet(start_packet)
137 |
138 | valid_readings: list[int] = []
139 | error = False
140 | tries = 0
141 | while len(valid_readings) < 6 and tries < 20:
142 | try:
143 | data: real_time_hr.Reading | real_time_hr.ReadingError = await asyncio.wait_for(
144 | self.queues[real_time_hr.CMD_START_HEART_RATE].get(),
145 | timeout=2,
146 | )
147 | if isinstance(data, real_time_hr.ReadingError):
148 | error = True
149 | break
150 | if data.value != 0:
151 | valid_readings.append(data.value)
152 | except TimeoutError:
153 | tries += 1
154 | await self.send_packet(real_time_hr.CONTINUE_HEART_RATE_PACKET)
155 |
156 | await self.send_packet(
157 | real_time_hr.STOP_HEART_RATE_PACKET,
158 | )
159 | if error:
160 | return None
161 | return valid_readings
162 |
163 | async def get_realtime_spo2(self) -> list[int] | None:
164 | return await self._poll_real_time_reading(real_time_hr.START_SPO2_PACKET)
165 |
166 | async def set_time(self, ts: datetime) -> None:
167 | await self.send_packet(set_time.set_time_packet(ts))
168 |
169 | async def blink_twice(self) -> None:
170 | await self.send_packet(blink_twice.BLINK_TWICE_PACKET)
171 |
172 | async def get_device_info(self) -> dict[str, str]:
173 | client = self.bleak_client
174 | data = {}
175 | device_info_service = client.services.get_service(DEVICE_INFO_UUID)
176 | assert device_info_service
177 |
178 | hw_info_char = device_info_service.get_characteristic(DEVICE_HW_UUID)
179 | assert hw_info_char
180 | hw_version = await client.read_gatt_char(hw_info_char)
181 | data["hw_version"] = hw_version.decode("utf-8")
182 |
183 | fw_info_char = device_info_service.get_characteristic(DEVICE_FW_UUID)
184 | assert fw_info_char
185 | fw_version = await client.read_gatt_char(fw_info_char)
186 | data["fw_version"] = fw_version.decode("utf-8")
187 |
188 | return data
189 |
190 | async def get_heart_rate_log(self, target: datetime | None = None) -> hr.HeartRateLog | hr.NoData:
191 | if target is None:
192 | target = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0)
193 | await self.send_packet(hr.read_heart_rate_packet(target))
194 | return await asyncio.wait_for(
195 | self.queues[hr.CMD_READ_HEART_RATE].get(),
196 | timeout=2,
197 | )
198 |
199 | async def get_heart_rate_log_settings(self) -> hr_settings.HeartRateLogSettings:
200 | await self.send_packet(hr_settings.READ_HEART_RATE_LOG_SETTINGS_PACKET)
201 | return await asyncio.wait_for(
202 | self.queues[hr_settings.CMD_HEART_RATE_LOG_SETTINGS].get(),
203 | timeout=2,
204 | )
205 |
206 | async def set_heart_rate_log_settings(self, enabled: bool, interval: int) -> None:
207 | await self.send_packet(hr_settings.hr_log_settings_packet(hr_settings.HeartRateLogSettings(enabled, interval)))
208 |
209 | # clear response from queue as it's unused and wrong
210 | await asyncio.wait_for(
211 | self.queues[hr_settings.CMD_HEART_RATE_LOG_SETTINGS].get(),
212 | timeout=2,
213 | )
214 |
215 | async def get_steps(self, target: datetime, today: datetime | None = None) -> list[steps.SportDetail] | steps.NoData:
216 | if today is None:
217 | today = datetime.now(timezone.utc)
218 |
219 | if target.tzinfo != timezone.utc:
220 | logger.info("Converting target time to utc")
221 | target = target.astimezone(tz=timezone.utc)
222 |
223 | days = (today.date() - target.date()).days
224 | logger.debug(f"Looking back {days} days")
225 |
226 | await self.send_packet(steps.read_steps_packet(days))
227 | return await asyncio.wait_for(
228 | self.queues[steps.CMD_GET_STEP_SOMEDAY].get(),
229 | timeout=2,
230 | )
231 |
232 | async def reboot(self) -> None:
233 | await self.send_packet(reboot.REBOOT_PACKET)
234 |
235 | async def raw(self, command: int, subdata: bytearray, replies: int = 0) -> list[bytearray]:
236 | p = packet.make_packet(command, subdata)
237 | await self.send_packet(p)
238 |
239 | results = []
240 | while replies > 0:
241 | data: bytearray = await asyncio.wait_for(
242 | self.queues[command].get(),
243 | timeout=2,
244 | )
245 | results.append(data)
246 | replies -= 1
247 |
248 | return results
249 |
--------------------------------------------------------------------------------
/docs/colmi_r02_client/blink_twice.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Open source python client to read your data from the Colmi R02 family of Smart Rings. 100% open source, 100% offline.
67 | 68 | 69 | 70 |
It's a cheap (as in $20) "smart ring" / fitness wearable that includes the following sensors:
75 | 76 |I found out about the ring from atc1441 and his work on ATC_RF03 and the 88 | Hackaday coverage
89 | 90 |Got questions or ideas?
91 | 92 |Are you hiring? Send me an email
99 | 100 |You can get it on here on AliExpress. If that link is dead try searching for "COLMI R02", I got mine from "Colmi official store". It cost me $CAD 22 shipped.
103 | 104 |The Colmi R06 is also compatible, and the R03 is probably compatible as well. Investigation into the R10 is ongoing, but if you have one please let me know if it works!
105 | 106 |If you don't know python that well, I highly recommend you install pipx. It's purpose built for managing python packages intended to be used as standalone programs and it will keep your computer safe from the pitfalls of python packaging. Once installed you can do
133 | 134 |pipx install git+https://github.com/tahnok/colmi_r02_client
136 |
137 | Once that is done you can look for nearby rings using
140 | 141 |colmi_r02_util scan
143 |
144 | Found device(s)
147 | Name | Address
148 | --------------------------------------------
149 | R02_341C | 70:CB:0D:D0:34:1C
150 |
151 |
152 | Once you have your address you can use it to do things like get real time heart rate
153 | 154 |colmi_r02_client --address=70:CB:0D:D0:34:1C get-real-time-heart-rate
156 |
157 | Starting reading, please wait.
160 | [81, 81, 79, 79, 79, 79]
161 |
162 |
163 | The most up to date and comprehensive help for the command line can be found running
164 | 165 |colmi_r02_client --help
167 |
168 | Usage: colmi_r02_client [OPTIONS] COMMAND [ARGS]...
171 |
172 | Options:
173 | --debug / --no-debug
174 | --record / --no-record Write all received packets to a file
175 | --address TEXT Bluetooth address
176 | --name TEXT Bluetooth name of the device, slower but will work
177 | on macOS
178 | --help Show this message and exit.
179 |
180 | Commands:
181 | get-heart-rate-log Get heart rate for given date
182 | get-heart-rate-log-settings Get heart rate log settings
183 | get-real-time-heart-rate Get real time heart rate.
184 | info Get device info and battery level
185 | set-heart-rate-log-settings Get heart rate log settings
186 | set-time Set the time on the ring, required if you...
187 |
188 |
189 | You can use the colmi_r02_client.client class as a library to do your own stuff in python. I've tried to write a lot of docstrings, which are visible on the docs site
I've kept a lab notebook style stream of consciousness notes on https://notes.tahnok.ca/, starting with 2024-07-07 Smart Ring Hacking and eventually getting put under one folder. That's the best source for all the raw stuff.
196 | 197 |At a high level though, you can talk to and read from the ring using BLE. There's no binding or security keys required to get started. (that's kind of bad, but the range on the ring is really tiny and I'm not too worried about someone getting my steps or heart rate information. Up to you).
198 | 199 |The ring has a BLE GATT service with the UUID 6E40FFF0-B5A3-F393-E0A9-E50E24DCCA9E. It has two important characteristics:
6E400002-B5A3-F393-E0A9-E50E24DCCA9E, which you write to6E400003-B5A3-F393-E0A9-E50E24DCCA9E, which you can "subscribe" to and is where the ring responds to packets you have sent.This closely resembles the Nordic UART Service and UART/Serial communications in general.
207 | 208 |The ring communicates in 16 byte packets for both sending and receiving. The first byte of the packet is always a command/tag/type. For example, the packet you send to ask for the battery level starts with 0x03 and the response packet also starts with 0x03.
The last byte of the packet is always a checksum/crc. This value is calculated by summing up the other 15 bytes in the packet and taking the result modulo 255. See colmi_r02_client.packet.checksum
The middle 14 bytes are the "subdata" or payload data. Some requests (like colmi_r02_client.set_time.set_time_packet) include additional data. Almost all responses use the subdata to return the data you asked for.
Some requests result in multiple responses that you have to consider together to get the data. colmi_r02_client.steps.SportDetailParser is an example of this behaviour.
If you want to know the actual packet structure for a given feature's request or response, take a look at the source code for that feature. I've tried to make it pretty easy to follow even if you don't know python very well. There are also some tests that you can refer to for validated request/response pairs and human readable interpretations of that data.
219 | 220 |Got questions or ideas? Send me an email or open an issue
221 | 222 |