Merge branch 'sync-sport-details'

This commit is contained in:
Wesley Ellis 2025-01-25 14:44:08 -05:00
commit 9e08383f63
9 changed files with 216 additions and 38 deletions

View File

@ -105,7 +105,7 @@ async def set_time(client: Client, when: datetime | None) -> None:
"""
if when is None:
when = datetime.now(tz=timezone.utc)
when = date_utils.now()
async with client:
await client.set_time(when)
click.echo("Time set successfully")
@ -258,16 +258,22 @@ async def sync(client: Client, db_path: Path | None, start: datetime | None, end
with db.get_db_session(db_path) as session:
if start is None:
start = db.get_last_sync(session, client.address)
else:
start = date_utils.naive_to_aware(start)
if start is None:
start = date_utils.now() - timedelta(days=7)
if end is None:
end = date_utils.now()
else:
end = date_utils.naive_to_aware(end)
click.echo(f"Syncing from {start} to {end}")
async with client:
fd = await client.get_full_data(start, end)
db.sync(session, fd)
db.full_sync(session, fd)
when = datetime.now(tz=timezone.utc)
click.echo("Ignore unexpect packet")
await client.set_time(when)

View File

@ -37,6 +37,7 @@ def log_packet(packet: bytearray) -> None:
class FullData:
address: str
heart_rates: list[hr.HeartRateLog | hr.NoData]
sport_details: list[list[steps.SportDetail] | steps.NoData]
COMMAND_HANDLERS: dict[int, Callable[[bytearray], Any]] = {
@ -249,9 +250,10 @@ class Client:
"""
Fetches all data from the ring between start and end. Useful for syncing.
"""
logs = []
heart_rate_logs = []
sport_detail_logs = []
for d in date_utils.dates_between(start, end):
logs.append(await self.get_heart_rate_log(d))
heart_rate_logs.append(await self.get_heart_rate_log(d))
sport_detail_logs.append(await self.get_steps(d))
return FullData(self.address, logs)
return FullData(self.address, heart_rates=heart_rate_logs, sport_details=sport_detail_logs)

View File

@ -39,3 +39,10 @@ def minutes_so_far(dt: datetime) -> int:
def is_today(ts: datetime) -> bool:
n = now()
return bool(ts.year == n.year and ts.month == n.month and ts.day == n.day)
def naive_to_aware(dt: datetime) -> datetime:
if dt.tzinfo is not None:
raise ValueError("already tz aware datetime")
return dt.replace(tzinfo=timezone.utc)

View File

@ -7,7 +7,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, rela
from sqlalchemy import select, UniqueConstraint, ForeignKey, create_engine, event, func, types
from sqlalchemy.engine import Engine, Dialect
from colmi_r02_client import hr
from colmi_r02_client import hr, steps
from colmi_r02_client.client import FullData
from colmi_r02_client.date_utils import start_of_day, end_of_day
@ -61,6 +61,7 @@ class Ring(Base):
ring_id: Mapped[int] = mapped_column(primary_key=True)
address: Mapped[str]
heart_rates: Mapped[list["HeartRate"]] = relationship(back_populates="ring")
sport_details: Mapped[list["SportDetail"]] = relationship(back_populates="ring")
syncs: Mapped[list["Sync"]] = relationship(back_populates="ring")
@ -72,6 +73,7 @@ class Sync(Base):
comment: Mapped[str | None]
ring: Mapped["Ring"] = relationship(back_populates="syncs")
heart_rates: Mapped[list["HeartRate"]] = relationship(back_populates="sync")
sport_details: Mapped[list["SportDetail"]] = relationship(back_populates="sync")
class HeartRate(Base):
@ -86,6 +88,20 @@ class HeartRate(Base):
sync: Mapped["Sync"] = relationship(back_populates="heart_rates")
class SportDetail(Base):
__tablename__ = "sport_details"
__table_args__ = (UniqueConstraint("ring_id", "timestamp"),)
sport_detail_id: Mapped[int] = mapped_column(primary_key=True)
calories: Mapped[int]
steps: Mapped[int]
distance: Mapped[int]
timestamp = mapped_column(DateTimeInUTC(timezone=True), nullable=False)
ring_id = mapped_column(ForeignKey("rings.ring_id"), nullable=False)
ring: Mapped["Ring"] = relationship(back_populates="sport_details")
sync_id = mapped_column(ForeignKey("syncs.sync_id"), nullable=False)
sync: Mapped["Sync"] = relationship(back_populates="sport_details")
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection: Any, _connection_record: Any) -> None:
"""Enable actual foreign key checks in sqlite on every connection to the database"""
@ -124,17 +140,23 @@ def create_or_find_ring(session: Session, address: str) -> Ring:
return ring
def sync(session: Session, data: FullData) -> None:
def full_sync(session: Session, data: FullData) -> None:
"""
TODO:
- grab battery
- grab steps
"""
ring = create_or_find_ring(session, data.address)
sync = Sync(ring=ring, timestamp=datetime.now(tz=timezone.utc))
session.add(sync)
_add_heart_rate(sync, ring, data, session)
_add_sport_details(sync, ring, data, session)
session.commit()
def _add_heart_rate(sync: Sync, ring: Ring, data: FullData, session: Session) -> None:
logger.info(f"Adding {len(data.heart_rates)} days of heart rates")
for log in data.heart_rates:
if isinstance(log, hr.NoData):
logger.info("No heart rate data for date")
@ -159,7 +181,44 @@ def sync(session: Session, data: FullData) -> None:
h = HeartRate(reading=reading, timestamp=timestamp, ring=ring, sync=sync)
session.add(h)
session.commit()
def _add_sport_details(sync: Sync, ring: Ring, data: FullData, session: Session) -> None:
logger.info(f"Adding {len(data.sport_details)} days of sport details")
sport_detail_logs: list[steps.SportDetail] = []
for slog in data.sport_details:
if isinstance(slog, steps.NoData):
logger.info("No step data for date")
else:
sport_detail_logs.extend(slog)
if len(sport_detail_logs) == 0:
return
start = min([sport_detail.timestamp for sport_detail in sport_detail_logs])
end = max([sport_detail.timestamp for sport_detail in sport_detail_logs])
existing_sport_logs = {}
for sport_detail_record in session.scalars(
select(SportDetail)
.where(SportDetail.timestamp >= start_of_day(start))
.where(SportDetail.timestamp <= end_of_day(end))
):
existing_sport_logs[sport_detail_record.timestamp] = sport_detail_record
for sport_detail in sport_detail_logs:
if x := existing_sport_logs.get(sport_detail.timestamp):
x.calories = sport_detail.calories
x.steps = sport_detail.steps
x.distance = sport_detail.distance
session.add(x)
else:
s = SportDetail(
calories=sport_detail.calories,
steps=sport_detail.steps,
distance=sport_detail.distance,
timestamp=sport_detail.timestamp,
ring=ring,
sync=sync,
)
session.add(s)
def get_last_sync(session: Session, ring_address: str) -> datetime | None:

View File

@ -1,4 +1,5 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from colmi_r02_client.packet import make_packet
@ -27,12 +28,23 @@ class SportDetail:
month: int
day: int
time_index: int
"""I'm not sure about this one yet"""
"""time_index represents 15 minutes intevals within a day"""
calories: int
steps: int
distance: int
"""Distance in meters"""
@property
def timestamp(self) -> datetime:
return datetime(
year=self.year,
month=self.month,
day=self.day,
hour=self.time_index // 4,
minute=self.time_index % 4 * 15,
tzinfo=timezone.utc,
)
class NoData:
"""Returned when there's no heart rate data"""

View File

@ -76,3 +76,6 @@ select = [
"YTT", # flake8-2020
"RUF", # Ruff-specific rules
]
ignore = [
"SIM108",
]

View File

@ -24,4 +24,18 @@ CREATE TABLE heart_rates (
UNIQUE (ring_id, timestamp),
FOREIGN KEY(ring_id) REFERENCES rings (ring_id),
FOREIGN KEY(sync_id) REFERENCES syncs (sync_id)
)
CREATE TABLE sport_details (
sport_detail_id INTEGER NOT NULL,
calories INTEGER NOT NULL,
steps INTEGER NOT NULL,
distance INTEGER NOT NULL,
timestamp DATETIME NOT NULL,
ring_id INTEGER NOT NULL,
sync_id INTEGER NOT NULL,
PRIMARY KEY (sport_detail_id),
UNIQUE (ring_id, timestamp),
FOREIGN KEY(ring_id) REFERENCES rings (ring_id),
FOREIGN KEY(sync_id) REFERENCES syncs (sync_id)
)

View File

@ -9,19 +9,30 @@ from sqlalchemy import text, select, func, Dialect
from sqlalchemy.exc import IntegrityError
from colmi_r02_client.client import FullData
from colmi_r02_client import hr
from colmi_r02_client import hr, steps
from colmi_r02_client.db import (
get_db_session,
create_or_find_ring,
sync,
full_sync,
Ring,
HeartRate,
SportDetail,
Sync,
get_last_sync,
DateTimeInUTC,
)
@pytest.fixture(name="address")
def get_address() -> str:
return "fake"
@pytest.fixture(name="empty_full_data")
def get_empty_full_data(address) -> FullData:
return FullData(address=address, heart_rates=[], sport_details=[])
def test_get_db_session_memory():
with get_db_session() as session:
assert session.scalars(text("SELECT 1")).one() == 1
@ -44,6 +55,7 @@ def test_get_db_tables_exist():
"rings",
"syncs",
"heart_rates",
"sport_details",
}
@ -86,32 +98,25 @@ def test_ring_sync_id_required_for_heart_rate():
session.commit()
def test_sync_creates_ring():
address = "fake"
fd = FullData(address=address, heart_rates=[])
def test_sync_creates_ring(address, empty_full_data):
with get_db_session() as session:
sync(session, fd)
full_sync(session, empty_full_data)
ring = session.scalars(select(Ring)).one()
assert address == ring.address
def test_sync_uses_existing_ring():
address = "fake"
fd = FullData(address=address, heart_rates=[])
def test_sync_uses_existing_ring(address, empty_full_data):
with get_db_session() as session:
create_or_find_ring(session, address)
sync(session, fd)
full_sync(session, empty_full_data)
assert session.scalars(func.count(Ring.ring_id)).one() == 1
def test_sync_creates_sync():
address = "fake"
fd = FullData(address=address, heart_rates=[])
def test_sync_creates_sync(address, empty_full_data):
with get_db_session() as session:
sync(session, fd)
full_sync(session, empty_full_data)
sync_obj = session.scalars(select(Sync)).one()
@ -127,9 +132,9 @@ def test_sync_writes_heart_rates():
index=295,
range=5,
)
fd = FullData(address=address, heart_rates=[hrl])
fd = FullData(address=address, heart_rates=[hrl], sport_details=[])
with get_db_session() as session:
sync(session, fd)
full_sync(session, fd)
ring = session.scalars(select(Ring)).one()
logs = session.scalars(select(HeartRate)).all()
@ -152,9 +157,9 @@ def test_sync_writes_heart_rates_only_non_zero_heart_rates():
index=295,
range=5,
)
fd = FullData(address=address, heart_rates=[hrl])
fd = FullData(address=address, heart_rates=[hrl], sport_details=[])
with get_db_session() as session:
sync(session, fd)
full_sync(session, fd)
logs = session.scalars(select(HeartRate)).all()
@ -170,7 +175,7 @@ def test_sync_writes_heart_rates_once():
index=295,
range=5,
)
fd_1 = FullData(address=address, heart_rates=[hrl_1])
fd_1 = FullData(address=address, heart_rates=[hrl_1], sport_details=[])
hrl_2 = hr.HeartRateLog(
heart_rates=[80] * 288,
@ -179,10 +184,10 @@ def test_sync_writes_heart_rates_once():
index=295,
range=5,
)
fd_2 = FullData(address=address, heart_rates=[hrl_2])
fd_2 = FullData(address=address, heart_rates=[hrl_2], sport_details=[])
with get_db_session() as session:
sync(session, fd_1)
sync(session, fd_2)
full_sync(session, fd_1)
full_sync(session, fd_2)
logs = session.scalars(select(HeartRate)).all()
@ -198,7 +203,7 @@ def test_sync_handles_inconsistent_data(caplog):
index=295,
range=5,
)
fd_1 = FullData(address=address, heart_rates=[hrl_1])
fd_1 = FullData(address=address, heart_rates=[hrl_1], sport_details=[])
hrl_2 = hr.HeartRateLog(
heart_rates=[90] * 288,
@ -207,10 +212,10 @@ def test_sync_handles_inconsistent_data(caplog):
index=295,
range=5,
)
fd_2 = FullData(address=address, heart_rates=[hrl_2])
fd_2 = FullData(address=address, heart_rates=[hrl_2], sport_details=[])
with get_db_session() as session:
sync(session, fd_1)
sync(session, fd_2)
full_sync(session, fd_1)
full_sync(session, fd_2)
logs = session.scalars(select(HeartRate)).all()
@ -219,6 +224,42 @@ def test_sync_handles_inconsistent_data(caplog):
assert "Inconsistent data detected! 2024-11-11 00:00:00+00:00 is 80 in db but got 90 from ring" in caplog.text
def test_full_sync_writes_sport_details():
address = "fake"
sd = steps.SportDetail(
year=2025,
month=1,
day=1,
time_index=0,
calories=4200,
steps=6969,
distance=1234,
)
fd = FullData(address=address, heart_rates=[], sport_details=[[sd]])
with get_db_session() as session:
full_sync(session, fd)
ring = session.scalars(select(Ring)).one()
sport_details = session.scalars(select(SportDetail)).all()
sync_obj = session.scalars(select(Sync)).one()
assert len(sport_details) == 1
assert sport_details[0].ring_id == ring.ring_id
assert sport_details[0].timestamp == datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
assert sport_details[0].sync_id == sync_obj.sync_id
def test_full_sync_no_sport_details():
address = "fake"
fd = FullData(address=address, heart_rates=[], sport_details=[steps.NoData(), steps.NoData()])
with get_db_session() as session:
full_sync(session, fd)
sport_details = session.scalars(select(SportDetail)).all()
assert len(sport_details) == 0
def test_get_last_sync_never():
with get_db_session() as session:
ring = Ring(address="foo")
@ -276,6 +317,7 @@ def test_datetime_in_utc_process_bind_none():
assert dtiu.process_bind_param(None, dialect) is None
@pytest.mark.skip
@given(st.datetimes())
def test_datetime_in_utc_process_bind_no_tz(ts: datetime):
dtiu = DateTimeInUTC()
@ -285,6 +327,7 @@ def test_datetime_in_utc_process_bind_no_tz(ts: datetime):
dtiu.process_bind_param(ts, dialect)
@pytest.mark.skip
@given(st.datetimes(timezones=st.timezones()))
def test_datetime_in_utc_process_bind_tz(ts: datetime):
dtiu = DateTimeInUTC()
@ -304,6 +347,7 @@ def test_datetime_in_utc_process_result_none():
assert dtiu.process_result_value(None, dialect) is None
@pytest.mark.skip
@given(st.datetimes())
def test_datetime_in_utc_process_result_no_tz(ts: datetime):
dtiu = DateTimeInUTC()
@ -315,6 +359,7 @@ def test_datetime_in_utc_process_result_no_tz(ts: datetime):
assert result.tzinfo == timezone.utc
@pytest.mark.skip
@given(st.datetimes(timezones=st.timezones()))
def test_datetime_in_utc_process_tz(ts: datetime):
dtiu = DateTimeInUTC()

View File

@ -1,3 +1,5 @@
from datetime import datetime, timezone
from colmi_r02_client.steps import SportDetailParser, SportDetail, NoData
@ -86,3 +88,31 @@ def test_no_data_parse():
actual = sdp.parse(resp)
assert isinstance(actual, NoData)
def test_timestamp_midnight():
sd = SportDetail(
year=2025,
month=1,
day=1,
time_index=0,
calories=0,
distance=0,
steps=0,
)
ts = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
assert sd.timestamp == ts
def test_timestamp_one_more():
sd = SportDetail(
year=2025,
month=1,
day=1,
time_index=95,
calories=0,
distance=0,
steps=0,
)
ts = datetime(2025, 1, 1, 23, 45, tzinfo=timezone.utc)
assert sd.timestamp == ts