From 974982db72edf1ed12a9a5196ceeb296a34082ad Mon Sep 17 00:00:00 2001 From: Wesley Ellis Date: Tue, 3 Sep 2024 21:33:41 -0400 Subject: [PATCH] Handle set_time_packet response by ignoring it There is an implementation included for parsing the response, which is some weird capabilities response but it's wrong --- colmi_r02_client/client.py | 1 + colmi_r02_client/main.py | 18 ++++++---- colmi_r02_client/set_time.py | 68 +++++++++++++++++++++++++++++++++++- tests/test_set_time.py | 39 ++++++++++++++++++++- 4 files changed, 118 insertions(+), 8 deletions(-) diff --git a/colmi_r02_client/client.py b/colmi_r02_client/client.py index bf9d8fa..af1c9a9 100644 --- a/colmi_r02_client/client.py +++ b/colmi_r02_client/client.py @@ -50,6 +50,7 @@ COMMAND_HANDLERS: dict[int, Callable[[bytearray], Any]] = { real_time_heart_rate.CMD_STOP_HEART_RATE: empty_parse, steps.CMD_GET_STEP_SOMEDAY: steps.SportDetailParser().parse, heart_rate.CMD_READ_HEART_RATE: heart_rate.HeartRateLogParser().parse, + set_time.CMD_SET_TIME: empty_parse, } diff --git a/colmi_r02_client/main.py b/colmi_r02_client/main.py index ae6970c..ced68c7 100644 --- a/colmi_r02_client/main.py +++ b/colmi_r02_client/main.py @@ -2,7 +2,7 @@ A python client for connecting to the Colmi R02 Smart ring """ -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path import logging import time @@ -76,14 +76,20 @@ async def get_heart_rate_log(client: Client, target: datetime) -> None: @cli_client.command() @click.option( - "--target", + "--when", type=click.DateTime(), - required=True, - help="The date you want logs for", + required=False, + help="The date and time you want to set the ring to", ) @click.pass_obj -async def set_time(client: Client, target: datetime) -> None: - await client.set_time(target) +async def set_time(client: Client, when: datetime | None) -> None: + """ + Set the time on the ring, required if you want to be able to interpret any of the logged data + """ + + if when is None: + when = datetime.now(tz=timezone.utc) + await client.set_time(when) DEVICE_NAME_PREFIXES = [ diff --git a/colmi_r02_client/set_time.py b/colmi_r02_client/set_time.py index 7f85827..29aabc5 100644 --- a/colmi_r02_client/set_time.py +++ b/colmi_r02_client/set_time.py @@ -1,12 +1,26 @@ -from datetime import datetime +""" +The smart ring has it's own internal clock that is used to determine what time a given heart rate or step took +place for accurate counting. + +We always set the time in UTC. +""" + +from datetime import datetime, timezone +import logging from colmi_r02_client.packet import make_packet +logger = logging.getLogger(__name__) CMD_SET_TIME = 1 def set_time_packet(target: datetime) -> bytearray: + if target.tzinfo != timezone.utc: + logger.info("Converting target time to utc") + target = target.astimezone(tz=timezone.utc) + + assert target.year >= 2000 data = bytearray(7) data[0] = byte_to_bcd(target.year % 2000) data[1] = byte_to_bcd(target.month) @@ -25,3 +39,55 @@ def byte_to_bcd(b: int) -> int: tens = b // 10 ones = b % 10 return (tens << 4) | ones + + +def parse_set_time_packet(packet: bytearray) -> dict[str, bool | int]: + """ + Parse the response to the set time packet which is some kind of capability response. + + It seems useless. It does correctly say avatar is not supported and that heart rate is supported. + But it also says there's wechat support and it supports 20 contacts. + + I think this is safe to swallow and ignore. + """ + assert packet[0] == CMD_SET_TIME + bArr = packet[1:] + data: dict[str, bool | int] = {} + data["mSupportTemperature"] = bArr[0] == 1 + data["mSupportPlate"] = bArr[1] == 1 + data["mSupportMenstruation"] = True + data["mSupportCustomWallpaper"] = (bArr[3] & 1) != 0 + data["mSupportBloodOxygen"] = (bArr[3] & 2) != 0 + data["mSupportBloodPressure"] = (bArr[3] & 4) != 0 + data["mSupportFeature"] = (bArr[3] & 8) != 0 + data["mSupportOneKeyCheck"] = (bArr[3] & 16) != 0 + data["mSupportWeather"] = (bArr[3] & 32) != 0 + data["mSupportWeChat"] = (bArr[3] & 64) == 0 + data["mSupportAvatar"] = (bArr[3] & 128) != 0 + # data["#width"] = ByteUtil.bytesToInt(Arrays.copyOfRange(bArr, 4, 6)) + # data["#height"] = ByteUtil.bytesToInt(Arrays.copyOfRange(bArr, 6, 8)) + data["mNewSleepProtocol"] = bArr[8] == 1 + data["mMaxWatchFace"] = bArr[9] + data["mSupportContact"] = (bArr[10] & 1) != 0 + data["mSupportLyrics"] = (bArr[10] & 2) != 0 + data["mSupportAlbum"] = (bArr[10] & 4) != 0 + data["mSupportGPS"] = (bArr[10] & 8) != 0 + data["mSupportJieLiMusic"] = (bArr[10] & 16) != 0 + data["mSupportManualHeart"] = (bArr[11] & 1) != 0 + data["mSupportECard"] = (bArr[11] & 2) != 0 + data["mSupportLocation"] = (bArr[11] & 4) != 0 + data["mMusicSupport"] = (bArr[11] & 16) != 0 + data["rtkMcu"] = (bArr[11] & 32) != 0 + data["mEbookSupport"] = (bArr[11] & 64) != 0 + data["mSupportBloodSugar"] = (bArr[11] & 128) != 0 + if bArr[12] == 0: + data["mMaxContacts"] = 20 + else: + data["mMaxContacts"] = bArr[12] * 10 + data["bpSettingSupport"] = (bArr[13] & 2) != 0 + data["mSupport4G"] = (bArr[13] & 4) != 0 + data["mSupportNavPicture"] = (bArr[13] & 8) != 0 + data["mSupportPressure"] = (bArr[13] & 16) != 0 + data["mSupportHrv"] = (bArr[13] & 32) != 0 + + return data diff --git a/tests/test_set_time.py b/tests/test_set_time.py index 1a8f658..b5cc52e 100644 --- a/tests/test_set_time.py +++ b/tests/test_set_time.py @@ -1,4 +1,6 @@ -from colmi_r02_client.set_time import byte_to_bcd +from datetime import datetime, timezone, timedelta + +from colmi_r02_client.set_time import byte_to_bcd, set_time_packet, CMD_SET_TIME, parse_set_time_packet import pytest @@ -12,3 +14,38 @@ def test_byte_to_bcd(normal, bcd): def test_byte_to_bcd_bad(bad): with pytest.raises(AssertionError): byte_to_bcd(bad) + + +def test_set_time_packet(): + ts = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + expected = bytearray(b"\x01$\x01\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00(") + + actual = set_time_packet(ts) + + assert actual == expected + + assert actual[0] == CMD_SET_TIME + + +def test_set_time_1999(): + ts = datetime(1999, 1, 1, 0, 0, 0) + with pytest.raises(AssertionError): + set_time_packet(ts) + + +def test_set_time_with_timezone(): + ts = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=-4))) + expected = bytearray(b"\x01$\x01\x01\x04\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00,") + + actual = set_time_packet(ts) + + assert actual == expected + + +def test_parse_set_time_response(): + packet = bytearray(b'\x01\x00\x01\x00"\x00\x00\x00\x00\x01\x000\x01\x00\x10f') + + capabilities = parse_set_time_packet(packet) + + assert capabilities["mSupportManualHeart"] + assert not capabilities["mSupportBloodSugar"]