├── 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 " 6 | readme = "README.md" 7 | requires-python = ">=3.9" 8 | dependencies = [ 9 | "bleak>=0.22.2", 10 | "asyncclick" 11 | ] 12 | 13 | [tool.uv] 14 | dev-dependencies = [ 15 | "freezegun>=1.5.1", 16 | "hypothesis>=6.115.5", 17 | "mypy>=1.13.0", 18 | "pytest>=8.3.3", 19 | "pytest-asyncio>=0.24.0", 20 | "ruff>=0.7.1", 21 | ] 22 | 23 | [build-system] 24 | requires = ["hatchling"] 25 | build-backend = "hatchling.build" 26 | 27 | #[build-system] 28 | #requires = ["poetry-core"] 29 | #build-backend = "poetry.core.masonry.api" 30 | 31 | [tool.poetry.scripts] 32 | colmi_r02_client = "colmi_r02_client.cli:cli_client" 33 | colmi_r02_util = "colmi_r02_client.cli:util" 34 | 35 | 36 | [tool.poetry.group.dev.dependencies] 37 | pytest = "^8.3.2" 38 | ruff = "^0.6.0" 39 | mypy = "^1.11.1" 40 | freezegun = "^1.5.1" 41 | hypothesis = "^6.112.0" 42 | pytest-asyncio = "^0.24.0" 43 | 44 | 45 | [tool.poetry.group.doc.dependencies] 46 | pdoc = "^14.6.1" 47 | 48 | 49 | [tool.poetry.group.nvim-lsp.dependencies] 50 | pyright = "^1.1.382.post0" 51 | 52 | 53 | [tool.mypy] 54 | warn_return_any = true 55 | warn_unused_configs = true 56 | warn_unused_ignores = true 57 | warn_no_return = true 58 | warn_redundant_casts = true 59 | strict_equality = true 60 | disallow_incomplete_defs = true 61 | 62 | [tool.pytest.ini_options] 63 | asyncio_mode = "auto" 64 | asyncio_default_fixture_loop_scope = "function" 65 | 66 | [tool.ruff] 67 | line-length = 125 68 | 69 | [tool.ruff.lint] 70 | select = [ 71 | "ASYNC", # flake8-async 72 | "B", # flake8-bugbear 73 | "C4", # flake8-comprehensions 74 | "DJ", # flake8-django 75 | "E", # pycodestyle 76 | "F", # Pyflakes 77 | "FLY", # flynt 78 | "INT", # flake8-gettext 79 | "NPY", # NumPy-specific rules 80 | "PD", # pandas-vet 81 | "PIE", # flake8-pie 82 | "PLE", # Pylint errors 83 | "RET504", # flake8-return 84 | "RSE", # flake8-raise 85 | "SIM", # flake8-simplify 86 | "T10", # flake8-debugger 87 | "TID", # flake8-tidy-imports 88 | "UP", # pyupgrade 89 | "W", # pycodestyle 90 | "YTT", # flake8-2020 91 | "RUF", # Ruff-specific rules 92 | ] 93 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock 2 | 3 | from asyncclick.testing import CliRunner 4 | 5 | from colmi_r02_client.cli import cli_client 6 | 7 | 8 | async def test_no_address_and_no_name(): 9 | runner = CliRunner() 10 | result = await runner.invoke(cli_client, ["info"]) 11 | assert result.exit_code == 2 12 | assert "Error: You must pass either the address option(preferred) or the name option, but not both" in result.output 13 | 14 | 15 | async def test_address_and_name(): 16 | runner = CliRunner() 17 | result = await runner.invoke( 18 | cli_client, 19 | [ 20 | "--name=foo", 21 | "--address=bar", 22 | "info", 23 | ], 24 | ) 25 | assert result.exit_code == 2 26 | assert "Error: You must pass either the address option(preferred) or the name option, but not both" in result.output 27 | 28 | 29 | @patch("colmi_r02_client.cli.Client", autospec=True) 30 | async def test_just_address(_client_mock): 31 | runner = CliRunner() 32 | result = await runner.invoke( 33 | cli_client, 34 | [ 35 | "--address=bar", 36 | "info", 37 | ], 38 | ) 39 | assert result.exit_code == 0 40 | 41 | 42 | @patch("colmi_r02_client.cli.Client", autospec=True) 43 | @patch("colmi_r02_client.cli.BleakScanner.discover", autospec=True) 44 | async def test_just_name(discover_mock, _client_mock): 45 | found = Mock() 46 | found.name = "bar" 47 | found.address = "foo" 48 | discover_mock.return_value = [found] 49 | 50 | runner = CliRunner() 51 | result = await runner.invoke( 52 | cli_client, 53 | [ 54 | "--name=bar", 55 | "info", 56 | ], 57 | ) 58 | assert result.exit_code == 0 59 | 60 | 61 | @patch("colmi_r02_client.cli.Client", autospec=True) 62 | @patch("colmi_r02_client.cli.BleakScanner.discover", autospec=True) 63 | async def test_just_name_not_found(discover_mock, _client_mock): 64 | found = Mock() 65 | found.name = "nonono" 66 | found.address = "foo" 67 | discover_mock.return_value = [found] 68 | 69 | runner = CliRunner() 70 | result = await runner.invoke( 71 | cli_client, 72 | [ 73 | "--name=bar", 74 | "info", 75 | ], 76 | ) 77 | assert result.exit_code == 2 78 | assert "Error: No device found with given name" in result.output 79 | -------------------------------------------------------------------------------- /tests/test_steps.py: -------------------------------------------------------------------------------- 1 | from colmi_r02_client.steps import SportDetailParser, SportDetail, NoData 2 | 3 | 4 | def test_parse_simple(): 5 | sdp = SportDetailParser() 6 | 7 | r = sdp.parse(bytearray(b"C\xf0\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x005")) 8 | 9 | assert r is None 10 | r = sdp.parse(bytearray(b"C$\x10\x15\\\x00\x01y\x00\x15\x00\x10\x00\x00\x00\x87")) 11 | 12 | assert r == [SportDetail(year=2024, month=10, day=15, time_index=92, calories=1210, steps=21, distance=16)] 13 | 14 | 15 | def test_parse_multi(): 16 | packets = [ 17 | bytearray(b"C\xf0\x05\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x009"), 18 | bytearray(b"C#\x08\x13\x10\x00\x05\xc8\x000\x00\x1b\x00\x00\x00\xa9"), 19 | bytearray(b"C#\x08\x13\x14\x01\x05\xb6\x18\xaa\x04i\x03\x00\x00\x83"), 20 | bytearray(b"C#\x08\x13\x18\x02\x058\x04\xe1\x00\x95\x00\x00\x00R"), 21 | bytearray(b"C#\x08\x13\x1c\x03\x05\x05\x02l\x00H\x00\x00\x00`"), 22 | bytearray(b"C#\x08\x13L\x04\x05\xef\x01c\x00D\x00\x00\x00m"), 23 | ] 24 | expected = [ 25 | SportDetail( 26 | year=2023, 27 | month=8, 28 | day=13, 29 | time_index=16, 30 | calories=2000, 31 | steps=48, 32 | distance=27, 33 | ), 34 | SportDetail( 35 | year=2023, 36 | month=8, 37 | day=13, 38 | time_index=20, 39 | calories=63260, 40 | steps=1194, 41 | distance=873, 42 | ), 43 | SportDetail( 44 | year=2023, 45 | month=8, 46 | day=13, 47 | time_index=24, 48 | calories=10800, 49 | steps=225, 50 | distance=149, 51 | ), 52 | SportDetail( 53 | year=2023, 54 | month=8, 55 | day=13, 56 | time_index=28, 57 | calories=5170, 58 | steps=108, 59 | distance=72, 60 | ), 61 | SportDetail( 62 | year=2023, 63 | month=8, 64 | day=13, 65 | time_index=76, 66 | calories=4950, 67 | steps=99, 68 | distance=68, 69 | ), 70 | ] 71 | 72 | sdp = SportDetailParser() 73 | for p in packets[:-1]: 74 | x = sdp.parse(p) 75 | assert x is None, f"Unexpected return from {p}" 76 | 77 | actual = sdp.parse(packets[-1]) 78 | 79 | assert actual == expected 80 | 81 | 82 | def test_no_data_parse(): 83 | resp = bytearray(b"C\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00B") 84 | sdp = SportDetailParser() 85 | 86 | actual = sdp.parse(resp) 87 | 88 | assert isinstance(actual, NoData) 89 | -------------------------------------------------------------------------------- /colmi_r02_client/steps.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from colmi_r02_client.packet import make_packet 4 | 5 | CMD_GET_STEP_SOMEDAY = 67 # 0x43 6 | 7 | 8 | def read_steps_packet(day_offset: int = 0) -> bytearray: 9 | """ 10 | Read the steps for a given day offset from "today" relative to the ring's internal clock. 11 | 12 | There's also 4 more bytes I don't fully understand but seem constant 13 | - 0x0f # constant 14 | - 0x00 # idk 15 | - 0x5f # less than 95 and greater than byte 16 | - 0x01 # constant 17 | """ 18 | sub_data = bytearray(b"\x00\x0f\x00\x5f\x01") 19 | sub_data[0] = day_offset 20 | 21 | return make_packet(CMD_GET_STEP_SOMEDAY, sub_data) 22 | 23 | 24 | @dataclass 25 | class SportDetail: 26 | year: int 27 | month: int 28 | day: int 29 | time_index: int 30 | """I'm not sure about this one yet""" 31 | calories: int 32 | steps: int 33 | distance: int 34 | """Distance in meters""" 35 | 36 | 37 | class NoData: 38 | """Returned when there's no heart rate data""" 39 | 40 | 41 | class SportDetailParser: 42 | r""" 43 | Parse SportDetailPacket, of which there will be several 44 | 45 | example data: 46 | bytearray(b'C\xf0\x05\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x009') 47 | bytearray(b'C#\x08\x13\x10\x00\x05\xc8\x000\x00\x1b\x00\x00\x00\xa9') 48 | bytearray(b'C#\x08\x13\x14\x01\x05\xb6\x18\xaa\x04i\x03\x00\x00\x83') 49 | bytearray(b'C#\x08\x13\x18\x02\x058\x04\xe1\x00\x95\x00\x00\x00R') 50 | bytearray(b'C#\x08\x13\x1c\x03\x05\x05\x02l\x00H\x00\x00\x00`') 51 | bytearray(b'C#\x08\x13L\x04\x05\xef\x01c\x00D\x00\x00\x00m') 52 | """ 53 | 54 | def __init__(self): 55 | self.reset() 56 | 57 | def reset(self) -> None: 58 | self.new_calorie_protocol = False 59 | self.index = 0 60 | self.details: list[SportDetail] = [] 61 | 62 | def parse(self, packet: bytearray) -> list[SportDetail] | None | NoData: 63 | assert len(packet) == 16 64 | assert packet[0] == CMD_GET_STEP_SOMEDAY 65 | 66 | if self.index == 0 and packet[1] == 255: 67 | self.reset() 68 | return NoData() 69 | 70 | if self.index == 0 and packet[1] == 240: 71 | if packet[3] == 1: 72 | self.new_calorie_protocol = True 73 | self.index += 1 74 | return None 75 | 76 | year = bcd_to_decimal(packet[1]) + 2000 77 | month = bcd_to_decimal(packet[2]) 78 | day = bcd_to_decimal(packet[3]) 79 | time_index = packet[4] 80 | calories = packet[7] | (packet[8] << 8) 81 | if self.new_calorie_protocol: 82 | calories *= 10 83 | steps = packet[9] | (packet[10] << 8) 84 | distance = packet[11] | (packet[12] << 8) 85 | 86 | details = SportDetail( 87 | year=year, 88 | month=month, 89 | day=day, 90 | time_index=time_index, 91 | calories=calories, 92 | steps=steps, 93 | distance=distance, 94 | ) 95 | self.details.append(details) 96 | 97 | if packet[5] == packet[6] - 1: 98 | x = self.details 99 | self.reset() 100 | return x 101 | else: 102 | self.index += 1 103 | return None 104 | 105 | 106 | def bcd_to_decimal(b: int) -> int: 107 | return (((b >> 4) & 15) * 10) + (b & 15) 108 | -------------------------------------------------------------------------------- /colmi_r02_client/set_time.py: -------------------------------------------------------------------------------- 1 | """ 2 | The smart ring has it's own internal clock that is used to determine what time a given heart rate or step took 3 | place for accurate counting. 4 | 5 | We always set the time in UTC. 6 | """ 7 | 8 | from datetime import datetime, timezone 9 | import logging 10 | 11 | from colmi_r02_client.packet import make_packet 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | CMD_SET_TIME = 1 16 | 17 | 18 | def set_time_packet(target: datetime) -> bytearray: 19 | if target.tzinfo != timezone.utc: 20 | logger.info("Converting target time to utc") 21 | target = target.astimezone(tz=timezone.utc) 22 | 23 | assert target.year >= 2000 24 | data = bytearray(7) 25 | data[0] = byte_to_bcd(target.year % 2000) 26 | data[1] = byte_to_bcd(target.month) 27 | data[2] = byte_to_bcd(target.day) 28 | data[3] = byte_to_bcd(target.hour) 29 | data[4] = byte_to_bcd(target.minute) 30 | data[5] = byte_to_bcd(target.second) 31 | data[6] = 1 # set language to english, 0 is chinese 32 | return make_packet(CMD_SET_TIME, data) 33 | 34 | 35 | def byte_to_bcd(b: int) -> int: 36 | assert b < 100 37 | assert b >= 0 38 | 39 | tens = b // 10 40 | ones = b % 10 41 | return (tens << 4) | ones 42 | 43 | 44 | def parse_set_time_packet(packet: bytearray) -> dict[str, bool | int]: 45 | """ 46 | Parse the response to the set time packet which is some kind of capability response. 47 | 48 | It seems useless. It does correctly say avatar is not supported and that heart rate is supported. 49 | But it also says there's wechat support and it supports 20 contacts. 50 | 51 | I think this is safe to swallow and ignore. 52 | """ 53 | assert packet[0] == CMD_SET_TIME 54 | bArr = packet[1:] 55 | data: dict[str, bool | int] = {} 56 | data["mSupportTemperature"] = bArr[0] == 1 57 | data["mSupportPlate"] = bArr[1] == 1 58 | data["mSupportMenstruation"] = True 59 | data["mSupportCustomWallpaper"] = (bArr[3] & 1) != 0 60 | data["mSupportBloodOxygen"] = (bArr[3] & 2) != 0 61 | data["mSupportBloodPressure"] = (bArr[3] & 4) != 0 62 | data["mSupportFeature"] = (bArr[3] & 8) != 0 63 | data["mSupportOneKeyCheck"] = (bArr[3] & 16) != 0 64 | data["mSupportWeather"] = (bArr[3] & 32) != 0 65 | data["mSupportWeChat"] = (bArr[3] & 64) == 0 66 | data["mSupportAvatar"] = (bArr[3] & 128) != 0 67 | # data["#width"] = ByteUtil.bytesToInt(Arrays.copyOfRange(bArr, 4, 6)) 68 | # data["#height"] = ByteUtil.bytesToInt(Arrays.copyOfRange(bArr, 6, 8)) 69 | data["mNewSleepProtocol"] = bArr[8] == 1 70 | data["mMaxWatchFace"] = bArr[9] 71 | data["mSupportContact"] = (bArr[10] & 1) != 0 72 | data["mSupportLyrics"] = (bArr[10] & 2) != 0 73 | data["mSupportAlbum"] = (bArr[10] & 4) != 0 74 | data["mSupportGPS"] = (bArr[10] & 8) != 0 75 | data["mSupportJieLiMusic"] = (bArr[10] & 16) != 0 76 | data["mSupportManualHeart"] = (bArr[11] & 1) != 0 77 | data["mSupportECard"] = (bArr[11] & 2) != 0 78 | data["mSupportLocation"] = (bArr[11] & 4) != 0 79 | data["mMusicSupport"] = (bArr[11] & 16) != 0 80 | data["rtkMcu"] = (bArr[11] & 32) != 0 81 | data["mEbookSupport"] = (bArr[11] & 64) != 0 82 | data["mSupportBloodSugar"] = (bArr[11] & 128) != 0 83 | if bArr[12] == 0: 84 | data["mMaxContacts"] = 20 85 | else: 86 | data["mMaxContacts"] = bArr[12] * 10 87 | data["bpSettingSupport"] = (bArr[13] & 2) != 0 88 | data["mSupport4G"] = (bArr[13] & 4) != 0 89 | data["mSupportNavPicture"] = (bArr[13] & 8) != 0 90 | data["mSupportPressure"] = (bArr[13] & 16) != 0 91 | data["mSupportHrv"] = (bArr[13] & 32) != 0 92 | 93 | return data 94 | -------------------------------------------------------------------------------- /colmi_r02_client/hr.py: -------------------------------------------------------------------------------- 1 | """This is called the DailyHeartRate in Java.""" 2 | 3 | from datetime import datetime, timezone, timedelta 4 | from dataclasses import dataclass 5 | import logging 6 | import struct 7 | 8 | from colmi_r02_client.packet import make_packet 9 | 10 | CMD_READ_HEART_RATE = 21 # 0x15 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def read_heart_rate_packet(target: datetime) -> bytearray: 16 | """target datetime should be at midnight for the day of interest""" 17 | data = bytearray(struct.pack(" int: 23 | """ 24 | Return the number of minutes elapsed in the day so far plus 1. 25 | 26 | I don't know why it's off by one, it just is. 27 | """ 28 | midnight = datetime(dt.year, dt.month, dt.day).timestamp() 29 | delta = dt.timestamp() - midnight # seconds since midnight 30 | 31 | return round(delta / 60) + 1 32 | 33 | 34 | def _add_times(heart_rates: list[int], ts: datetime) -> list[tuple[int, datetime]]: 35 | assert len(heart_rates) == 288, "Need exactly 288 points at 5 minute intervals" 36 | result = [] 37 | m = datetime(ts.year, ts.month, ts.day) 38 | five_min = timedelta(minutes=5) 39 | for hr in heart_rates: 40 | result.append((hr, m)) 41 | m += five_min 42 | 43 | return result 44 | 45 | 46 | @dataclass 47 | class HeartRateLog: 48 | heart_rates: list[int] 49 | timestamp: datetime 50 | size: int 51 | index: int 52 | range: int 53 | 54 | def heart_rates_with_times(self): 55 | return _add_times(self.heart_rates, self.timestamp) 56 | 57 | 58 | class NoData: 59 | """Returned when there's no heart rate data""" 60 | 61 | 62 | class HeartRateLogParser: 63 | def __init__(self): 64 | self.reset() 65 | 66 | def reset(self): 67 | self._raw_heart_rates = [] 68 | self.timestamp = None 69 | self.size = 0 70 | self.index = 0 71 | self.end = False 72 | self.range = 5 73 | 74 | def is_today(self) -> bool: 75 | d = self.timestamp 76 | if d is None: 77 | return False 78 | now = datetime.now() # use local time 79 | logger.info(f"Comparing {d} to {now}") 80 | return bool(d.year == now.year and d.month == now.month and d.day == now.day) 81 | 82 | def parse(self, packet: bytearray) -> HeartRateLog | NoData | None: 83 | r""" 84 | first byte of packet should always be CMD_READ_HEART_RATE (21) 85 | second byte is the sub_type 86 | 87 | sub_type 0 contains the lengths of things 88 | byte 2 is the number of expected packets after this. 89 | 90 | example: bytearray(b'\x15\x00\x18\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x002'), 91 | 92 | 93 | """ 94 | 95 | sub_type = packet[1] 96 | if sub_type == 255: 97 | logger.info("error response from heart rate log request") 98 | self.reset() 99 | return NoData() 100 | if self.is_today() and sub_type == 23: 101 | assert self.timestamp 102 | result = HeartRateLog( 103 | heart_rates=self.heart_rates, 104 | timestamp=self.timestamp, 105 | size=self.size, 106 | range=self.range, 107 | index=self.index, 108 | ) 109 | self.reset() 110 | return result 111 | if sub_type == 0: 112 | self.end = False 113 | self.size = packet[2] # number of expected packets 114 | self.range = packet[3] 115 | self._raw_heart_rates = [-1] * (self.size * 13) 116 | return None 117 | elif sub_type == 1: 118 | # next 4 bytes are a timestamp 119 | ts = struct.unpack_from(" 288: 158 | hr = hr[0:288] 159 | elif len(self._raw_heart_rates) < 288: 160 | hr.extend([0] * (288 - len(hr))) 161 | if self.is_today(): 162 | m = _minutes_so_far(datetime.now(tz=timezone.utc)) // 5 163 | hr[m:] = [0] * len(hr[m:]) 164 | 165 | return hr 166 | -------------------------------------------------------------------------------- /tests/test_hr.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from freezegun import freeze_time 4 | 5 | from colmi_r02_client.hr import ( 6 | HeartRateLogParser, 7 | HeartRateLog, 8 | NoData, 9 | _minutes_so_far, 10 | ) 11 | 12 | HEART_RATE_PACKETS = [ 13 | bytearray(b"\x15\x00\x18\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x002"), 14 | bytearray(b"\x15\x01\x80\xad\xb6f\x00\x00\x00\x00\x00\x00\x00\x00\x00_"), 15 | bytearray(b"\x15\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x17"), 16 | bytearray(b"\x15\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18"), 17 | bytearray(b"\x15\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x19"), 18 | bytearray(b"\x15\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a"), 19 | bytearray(b"\x15\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1b"), 20 | bytearray(b"\x15\x07\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1c"), 21 | bytearray(b"\x15\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d"), 22 | bytearray(b"\x15\t\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1e"), 23 | bytearray(b"\x15\n\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f"), 24 | bytearray(b"\x15\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 "), 25 | bytearray(b"\x15\x0c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00!"), 26 | bytearray(b'\x15\r\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"'), 27 | bytearray(b"\x15\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#"), 28 | bytearray(b"\x15\x0f\x00\x00Y\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00}"), 29 | bytearray(b"\x15\x10\x00k\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x90"), 30 | bytearray(b"\x15\x11`\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00k\xf1"), 31 | bytearray(b"\x15\x12\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'"), 32 | bytearray(b"\x15\x13\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00P\x00\x00x"), 33 | bytearray(b"\x15\x14\x00\x00\x00\x00\x00\x00\x00\x00\x00F\x00\x00\x00o"), 34 | bytearray(b"\x15\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00*"), 35 | bytearray(b"\x15\x16\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00+"), 36 | bytearray(b"\x15\x17\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00,"), 37 | ] 38 | 39 | 40 | def test_parse_return_none_until_end(): 41 | parser = HeartRateLogParser() 42 | for p in HEART_RATE_PACKETS[:-1]: 43 | assert parser.parse(p) is None 44 | 45 | 46 | def test_parse_until_end(): 47 | parser = HeartRateLogParser() 48 | for p in HEART_RATE_PACKETS[:-1]: 49 | parser.parse(p) 50 | 51 | result = parser.parse(HEART_RATE_PACKETS[-1]) 52 | 53 | assert isinstance(result, HeartRateLog) 54 | 55 | assert len(result.heart_rates) == 288 56 | 57 | expected_timestamp = datetime( 58 | year=2024, 59 | month=8, 60 | day=10, 61 | hour=0, 62 | minute=0, 63 | tzinfo=timezone.utc, 64 | ) 65 | assert result.timestamp == expected_timestamp 66 | 67 | 68 | def test_parse_no_data(): 69 | parser = HeartRateLogParser() 70 | result = parser.parse( 71 | bytearray(b"\x15\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x14"), 72 | ) 73 | assert isinstance(result, NoData) 74 | 75 | 76 | @freeze_time("2024-01-01") 77 | def test_is_today_today(): 78 | parser = HeartRateLogParser() 79 | parser._raw_heart_rates = [1] * 288 80 | parser.timestamp = datetime(2024, 1, 1, 1, 1, 0) 81 | 82 | assert parser.is_today() 83 | 84 | 85 | @freeze_time("2024-01-02") 86 | def test_is_today_not_today(): 87 | parser = HeartRateLogParser() 88 | parser._raw_heart_rates = [1] * 288 89 | parser.timestamp = datetime(2024, 1, 1, 1, 1, 0) 90 | 91 | assert not parser.is_today() 92 | 93 | 94 | def test_heart_rates_less_288(): 95 | """Test that we pad the heart rate array to 288 with 0s if the raw data is less than 288 bytes long.""" 96 | 97 | parser = HeartRateLogParser() 98 | parser._raw_heart_rates = [1] * 286 99 | 100 | hr = parser.heart_rates 101 | 102 | assert len(hr) == 288 103 | assert hr == (([1] * 286) + [0, 0]) 104 | 105 | 106 | def test_get_heart_rate_more_288(): 107 | parser = HeartRateLogParser() 108 | parser._raw_heart_rates = [1] * 289 109 | 110 | hr = parser.heart_rates 111 | 112 | assert len(hr) == 288 113 | assert hr == ([1] * 288) 114 | 115 | 116 | def test_get_heart_rate_288_not_today(): 117 | parser = HeartRateLogParser() 118 | parser._raw_heart_rates = [1] * 288 119 | parser.timestamp = datetime(2020, 1, 1, 1, 1, 0) 120 | 121 | hr = parser.heart_rates 122 | 123 | assert len(hr) == 288 124 | assert hr == ([1] * 288) 125 | 126 | 127 | @freeze_time("2024-01-01 01:00") 128 | def test_get_heart_rate_288_today(): 129 | parser = HeartRateLogParser() 130 | parser._raw_heart_rates = [1] * 288 131 | parser.timestamp = datetime(2024, 1, 1, 1, 1, 0) 132 | 133 | hr = parser.heart_rates 134 | 135 | assert len(hr) == 288 136 | assert hr == ([1] * 12) + ([0] * 276) 137 | 138 | 139 | def test_minutes_so_far_midnight(): 140 | x = datetime(2024, 1, 1) 141 | assert _minutes_so_far(x) == 1 142 | 143 | 144 | def test_minutes_so_far_minutes(): 145 | x = datetime(2024, 1, 1, 0, 15) 146 | assert _minutes_so_far(x) == 16 147 | 148 | 149 | def test_minutes_so_far_day(): 150 | x = datetime(2024, 1, 1, 23, 59) 151 | assert _minutes_so_far(x) == 1440 152 | 153 | 154 | def test_with_times(): 155 | h = HeartRateLog([60] * 288, datetime(2024, 1, 1, 5), 0, 0, 5) 156 | 157 | hr_with_ts = h.heart_rates_with_times() 158 | 159 | assert len(hr_with_ts) == 288 160 | assert hr_with_ts[0][1] == datetime(2024, 1, 1, 0, 0) 161 | assert hr_with_ts[-1][1] == datetime(2024, 1, 1, 23, 55) 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Colmi R02 Client 2 | 3 | Open source python client to read your data from the Colmi R02 family of Smart Rings. 100% open source, 100% offline. 4 | 5 | [Source code on GitHub](https://github.com/tahnok/colmi_r02_client) 6 | 7 | ## What is the Colmi R02? 8 | 9 | picture of the colmi r02 smart ring in shiny black. The electronics can be seen through the epoxy inside the ring 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 | colmi_r02_client.blink_twice API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 50 |
51 |
52 |

53 | colmi_r02_client.blink_twice

54 | 55 | 56 | 57 | 58 | 59 | 60 |
1from colmi_r02_client.packet import make_packet
 61 | 2
 62 | 3CMD_BLINK_TWICE = 16  # 0x10
 63 | 4
 64 | 5BLINK_TWICE_PACKET = make_packet(CMD_BLINK_TWICE)
 65 | 
66 | 67 | 68 |
69 | 81 | 93 |
94 | 276 | -------------------------------------------------------------------------------- /docs/colmi_r02_client/reboot.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | colmi_r02_client.reboot API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 50 |
51 |
52 |

53 | colmi_r02_client.reboot

54 | 55 | 56 | 57 | 58 | 59 | 60 |
1from colmi_r02_client.packet import make_packet
 61 | 2
 62 | 3CMD_REBOOT = 8  # 0x08
 63 | 4
 64 | 5REBOOT_PACKET = make_packet(CMD_REBOOT, bytearray(b"\x01"))
 65 | 
66 | 67 | 68 |
69 |
70 |
71 | CMD_REBOOT = 72 | 8 73 | 74 | 75 |
76 | 77 | 78 | 79 | 80 |
81 |
82 |
83 | REBOOT_PACKET = 84 | bytearray(b'\x08\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\t') 85 | 86 | 87 |
88 | 89 | 90 | 91 | 92 |
93 |
94 | 276 | -------------------------------------------------------------------------------- /docs/colmi_r02_client.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | colmi_r02_client API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 61 |
62 |
63 |

64 | colmi_r02_client

65 | 66 |

Open source python client to read your data from the Colmi R02 family of Smart Rings. 100% open source, 100% offline.

67 | 68 |

Source code on GitHub

69 | 70 |

What is the Colmi R02?

71 | 72 |

picture of the colmi r02 smart ring in shiny black. The electronics can be seen through the epoxy inside the ring

73 | 74 |

It's a cheap (as in $20) "smart ring" / fitness wearable that includes the following sensors:

75 | 76 |
    77 |
  • Accelerometer 78 |
      79 |
    • step tracking
    • 80 |
    • sleep tracking
    • 81 |
    • gestures (maybe...?)
    • 82 |
  • 83 |
  • Heart Rate (HR)
  • 84 |
  • Blood Oxygen (SPO2)
  • 85 |
86 | 87 |

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 | 97 | 98 |

Are you hiring? Send me an email

99 | 100 |

How to buy

101 | 102 |

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 |

Reverse engineering status

107 | 108 |
    109 |
  • Real time heart rate and SPO2
  • 110 |
  • Step logs (still don't quite understand how the day is split up)
  • 111 |
  • Heart rate logs (aka periodic measurement)
  • 112 |
  • Set ring time
  • 113 |
  • Set HR log frequency
  • 114 |
  • SPO2 logs
  • 115 |
  • Sleep tracking
  • 116 |
  • "Stress" measurement
  • 117 |
118 | 119 |

Planned Feature

120 | 121 |
    122 |
  • add more CLI functionality
  • 123 |
  • pretty print HR and steps
  • 124 |
  • sync all data to a file or SQLite db
  • 125 |
  • simple web interface
  • 126 |
127 | 128 |

Getting started

129 | 130 |

Using the command line

131 | 132 |

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 |
135 |
pipx install git+https://github.com/tahnok/colmi_r02_client
136 | 
137 |
138 | 139 |

Once that is done you can look for nearby rings using

140 | 141 |
142 |
colmi_r02_util scan
143 | 
144 |
145 | 146 |
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 |
155 |
colmi_r02_client --address=70:CB:0D:D0:34:1C get-real-time-heart-rate
156 | 
157 |
158 | 159 |
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 |
166 |
colmi_r02_client --help
167 | 
168 |
169 | 170 |
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 |

With the library / SDK

190 | 191 |

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

192 | 193 |

Communication Protocol Details

194 | 195 |

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:

200 | 201 |
    202 |
  1. RX: 6E400002-B5A3-F393-E0A9-E50E24DCCA9E, which you write to
  2. 203 |
  3. TX: 6E400003-B5A3-F393-E0A9-E50E24DCCA9E, which you can "subscribe" to and is where the ring responds to packets you have sent.
  4. 204 |
205 | 206 |

This closely resembles the Nordic UART Service and UART/Serial communications in general.

207 | 208 |

Packet structure

209 | 210 |

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.

211 | 212 |

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

213 | 214 |

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.

215 | 216 |

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.

217 | 218 |

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 | 223 | 224 | 228 |
229 | 230 | 231 | 232 | 233 | 234 |
1"""
235 | 2.. include:: ../README.md
236 | 3    :start-line: 2
237 | 4"""
238 | 
239 | 240 | 241 |
242 |
243 | 425 | --------------------------------------------------------------------------------