mifit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mifit/__init__.py +67 -0
- mifit/auth/__init__.py +0 -0
- mifit/auth/lifetime.py +140 -0
- mifit/auth/qr.py +558 -0
- mifit/auth/renew.py +110 -0
- mifit/auth/session.py +155 -0
- mifit/chunks.py +239 -0
- mifit/cli/__init__.py +755 -0
- mifit/cli/__main__.py +5 -0
- mifit/cli/partial.py +241 -0
- mifit/client.py +540 -0
- mifit/crypto.py +138 -0
- mifit/domains/__init__.py +0 -0
- mifit/domains/activity_daily.py +127 -0
- mifit/domains/base.py +160 -0
- mifit/domains/consumption.py +102 -0
- mifit/domains/daily_report.py +141 -0
- mifit/domains/days.py +69 -0
- mifit/domains/heart_rate.py +138 -0
- mifit/domains/heart_rate_daily.py +69 -0
- mifit/domains/passthrough.py +136 -0
- mifit/domains/profile.py +81 -0
- mifit/domains/raw_window.py +81 -0
- mifit/domains/sleep.py +332 -0
- mifit/domains/spo2.py +75 -0
- mifit/domains/spo2_daily.py +69 -0
- mifit/domains/steps.py +102 -0
- mifit/domains/stress_daily.py +84 -0
- mifit/domains/weight.py +79 -0
- mifit/domains/workout_detail.py +472 -0
- mifit/domains/workouts.py +212 -0
- mifit/errors.py +72 -0
- mifit/fds/__init__.py +36 -0
- mifit/fds/binary.py +137 -0
- mifit/fds/cipher.py +74 -0
- mifit/fds/download.py +224 -0
- mifit/fds/ids.py +76 -0
- mifit/fds/layouts.py +273 -0
- mifit/fds/urls.py +94 -0
- mifit/models/__init__.py +0 -0
- mifit/models/activity.py +33 -0
- mifit/models/consumption.py +30 -0
- mifit/models/heart_rate.py +40 -0
- mifit/models/profile.py +32 -0
- mifit/models/sleep.py +74 -0
- mifit/models/spo2.py +38 -0
- mifit/models/steps.py +29 -0
- mifit/models/stress.py +35 -0
- mifit/models/weight.py +24 -0
- mifit/models/workout_detail.py +62 -0
- mifit/models/workouts.py +46 -0
- mifit/paths.py +30 -0
- mifit/py.typed +0 -0
- mifit/region.py +280 -0
- mifit/transport.py +369 -0
- mifit-0.2.0.dist-info/METADATA +397 -0
- mifit-0.2.0.dist-info/RECORD +62 -0
- mifit-0.2.0.dist-info/WHEEL +5 -0
- mifit-0.2.0.dist-info/entry_points.txt +2 -0
- mifit-0.2.0.dist-info/licenses/LICENSE +21 -0
- mifit-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES +50 -0
- mifit-0.2.0.dist-info/top_level.txt +1 -0
mifit/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""mifit: read your Mi Fitness data from the Xiaomi cloud."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
|
|
5
|
+
from mifit.auth.session import Session
|
|
6
|
+
from mifit.client import MiFit
|
|
7
|
+
from mifit.errors import (
|
|
8
|
+
ApiError,
|
|
9
|
+
AuthError,
|
|
10
|
+
MiFitError,
|
|
11
|
+
NotificationRequiredError,
|
|
12
|
+
RenewalError,
|
|
13
|
+
)
|
|
14
|
+
from mifit.models.activity import ActivityDailySummary
|
|
15
|
+
from mifit.models.consumption import ConsumptionRecord
|
|
16
|
+
from mifit.models.heart_rate import HeartRateDailySummary, HeartRatePoint
|
|
17
|
+
from mifit.models.profile import UserProfile
|
|
18
|
+
from mifit.models.sleep import SleepRecord, SleepSession, SleepStage
|
|
19
|
+
from mifit.models.spo2 import Spo2DailySummary, Spo2Point
|
|
20
|
+
from mifit.models.steps import StepRecord
|
|
21
|
+
from mifit.models.stress import StressDailySummary
|
|
22
|
+
from mifit.models.weight import WeightRecord
|
|
23
|
+
from mifit.models.workout_detail import TrackPoint, WorkoutDetail, WorkoutSample
|
|
24
|
+
from mifit.models.workouts import Workout
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _installed_version(dist: str = "mifit") -> str:
|
|
28
|
+
"""The version of the installed ``dist`` distribution, from its metadata.
|
|
29
|
+
|
|
30
|
+
The single source of the version is ``pyproject.toml``; an install (regular
|
|
31
|
+
or editable) records it in the metadata read here. Imported from a source
|
|
32
|
+
tree that was never installed, there is no metadata: ``"0+unknown"``.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
return importlib.metadata.version(dist)
|
|
36
|
+
except importlib.metadata.PackageNotFoundError:
|
|
37
|
+
return "0+unknown"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__version__ = _installed_version()
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"MiFit",
|
|
44
|
+
"Session",
|
|
45
|
+
"MiFitError",
|
|
46
|
+
"AuthError",
|
|
47
|
+
"ApiError",
|
|
48
|
+
"NotificationRequiredError",
|
|
49
|
+
"RenewalError",
|
|
50
|
+
"StepRecord",
|
|
51
|
+
"HeartRatePoint",
|
|
52
|
+
"HeartRateDailySummary",
|
|
53
|
+
"ConsumptionRecord",
|
|
54
|
+
"SleepRecord",
|
|
55
|
+
"SleepSession",
|
|
56
|
+
"SleepStage",
|
|
57
|
+
"Workout",
|
|
58
|
+
"WorkoutDetail",
|
|
59
|
+
"WorkoutSample",
|
|
60
|
+
"TrackPoint",
|
|
61
|
+
"Spo2DailySummary",
|
|
62
|
+
"Spo2Point",
|
|
63
|
+
"StressDailySummary",
|
|
64
|
+
"ActivityDailySummary",
|
|
65
|
+
"WeightRecord",
|
|
66
|
+
"UserProfile",
|
|
67
|
+
]
|
mifit/auth/__init__.py
ADDED
|
File without changes
|
mifit/auth/lifetime.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Observe how long a session lives: its ages, a one-line description and an event log.
|
|
2
|
+
|
|
3
|
+
The service token's real lifetime is not documented anywhere, so the SDK records
|
|
4
|
+
when each session event happens and how old the session and its token were at
|
|
5
|
+
that moment. The events are ``login``; ``unauthorized`` (a request got HTTP 401);
|
|
6
|
+
and then how the renewal that follows ended: ``renewed``, ``renew_failed`` (the
|
|
7
|
+
account service refused the stored passToken, or the exchange failed),
|
|
8
|
+
``renew_unavailable`` (the session has no passToken or user id, so nothing was
|
|
9
|
+
sent) or ``renew_network_error`` (the renewal did not complete because of a network
|
|
10
|
+
failure, which can come after the passToken was accepted, so it says nothing about
|
|
11
|
+
the passToken). Only ``renew_failed`` can bear on the passToken's lifetime, and only
|
|
12
|
+
as an upper bound on refusals: a failed exchange after an accepted passToken is
|
|
13
|
+
logged as ``renew_failed`` too. A 401 can also come from a refused request
|
|
14
|
+
signature, so an ``unauthorized`` is not always an expiry. The log holds only
|
|
15
|
+
event names, UTC times and ages in seconds: never a token, a cookie, a user id or
|
|
16
|
+
a device id.
|
|
17
|
+
Writing it is best effort and never raises, so a read-only disk can not turn a
|
|
18
|
+
working request into a failure.
|
|
19
|
+
|
|
20
|
+
Times are the UTC ISO-8601 strings stored on ``Session`` (``2026-10-14T03:18:19Z``).
|
|
21
|
+
A time that does not parse gives an age of ``None`` ("unknown"); a time in the
|
|
22
|
+
future (a clock set back) counts as age 0.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from mifit.auth.session import Session
|
|
32
|
+
from mifit.paths import default_session_log_path
|
|
33
|
+
|
|
34
|
+
__all__ = ["describe_session", "format_age", "record_event", "session_ages"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _as_utc(moment: datetime) -> datetime:
|
|
38
|
+
"""``moment`` in UTC; a naive datetime is taken to be UTC already."""
|
|
39
|
+
if moment.tzinfo is None:
|
|
40
|
+
return moment.replace(tzinfo=timezone.utc)
|
|
41
|
+
return moment.astimezone(timezone.utc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_time(value: object) -> datetime | None:
|
|
45
|
+
"""Parse a stored ISO-8601 time to an aware UTC datetime, or ``None`` if it is not one."""
|
|
46
|
+
if not isinstance(value, str):
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return _as_utc(datetime.fromisoformat(value))
|
|
50
|
+
except (ValueError, OverflowError):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _age_s(then: str | None, now: datetime) -> int | None:
|
|
55
|
+
moment = _parse_time(then)
|
|
56
|
+
if moment is None:
|
|
57
|
+
return None
|
|
58
|
+
return max(0, int((now - moment).total_seconds()))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _now(now: datetime | None) -> datetime:
|
|
62
|
+
return datetime.now(timezone.utc) if now is None else _as_utc(now)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def session_ages(
|
|
66
|
+
session: Session, now: datetime | None = None
|
|
67
|
+
) -> tuple[int | None, int | None]:
|
|
68
|
+
"""Return ``(login_age_s, token_age_s)``: seconds since login and since the token was issued.
|
|
69
|
+
|
|
70
|
+
The token age counts from ``renewed_at``, or from ``created_at`` when the
|
|
71
|
+
session was never renewed. An unparsable time gives ``None`` for that age and
|
|
72
|
+
a time in the future gives 0. ``now`` defaults to the current time.
|
|
73
|
+
"""
|
|
74
|
+
moment = _now(now)
|
|
75
|
+
token_since = session.renewed_at or session.created_at
|
|
76
|
+
return _age_s(session.created_at, moment), _age_s(token_since, moment)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_age(seconds: int | None) -> str:
|
|
80
|
+
"""Render an age for people: ``unknown``, ``45m``, ``7h02m`` or ``2d03h``."""
|
|
81
|
+
if seconds is None:
|
|
82
|
+
return "unknown"
|
|
83
|
+
minutes = seconds // 60
|
|
84
|
+
if minutes < 60:
|
|
85
|
+
return f"{minutes}m"
|
|
86
|
+
hours, minutes = divmod(minutes, 60)
|
|
87
|
+
if hours < 24:
|
|
88
|
+
return f"{hours}h{minutes:02d}m"
|
|
89
|
+
days, hours = divmod(hours, 24)
|
|
90
|
+
return f"{days}d{hours:02d}h"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def describe_session(session: Session, now: datetime | None = None) -> str:
|
|
94
|
+
"""One line on the session's age and whether it can renew itself; no credential in it."""
|
|
95
|
+
login_age_s, token_age_s = session_ages(session, now)
|
|
96
|
+
text = f"logged in {session.created_at} ({format_age(login_age_s)} ago); "
|
|
97
|
+
if session.renewed_at:
|
|
98
|
+
text += f"last renewed {session.renewed_at} ({format_age(token_age_s)} ago)"
|
|
99
|
+
else:
|
|
100
|
+
text += "never renewed"
|
|
101
|
+
if session.can_renew():
|
|
102
|
+
return text + "; automatic renewal available"
|
|
103
|
+
return (
|
|
104
|
+
text
|
|
105
|
+
+ "; automatic renewal unavailable (no passToken: run `mifit login` once to enable it)"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def record_event(
|
|
110
|
+
event: str,
|
|
111
|
+
session: Session,
|
|
112
|
+
*,
|
|
113
|
+
now: datetime | None = None,
|
|
114
|
+
path: Path | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Append one line about ``event`` to the session log; never raise.
|
|
117
|
+
|
|
118
|
+
The line is ``{"event", "at", "login_age_s", "token_age_s"}`` with ``at`` in
|
|
119
|
+
UTC. ``path`` defaults to ``paths.default_session_log_path()``. The parent
|
|
120
|
+
directory is created owner-only (on POSIX). Any ``OSError`` is swallowed, and
|
|
121
|
+
so is the ``RuntimeError`` that ``Path.home()`` raises when there is no home
|
|
122
|
+
directory to put the default log in.
|
|
123
|
+
"""
|
|
124
|
+
moment = _now(now)
|
|
125
|
+
login_age_s, token_age_s = session_ages(session, moment)
|
|
126
|
+
line = json.dumps(
|
|
127
|
+
{
|
|
128
|
+
"event": event,
|
|
129
|
+
"at": moment.isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
130
|
+
"login_age_s": login_age_s,
|
|
131
|
+
"token_age_s": token_age_s,
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
target = Path(path) if path is not None else default_session_log_path()
|
|
136
|
+
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
137
|
+
with open(target, "a", encoding="utf-8", newline="\n") as handle:
|
|
138
|
+
handle.write(line + "\n")
|
|
139
|
+
except (OSError, RuntimeError):
|
|
140
|
+
pass
|