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.
Files changed (62) hide show
  1. mifit/__init__.py +67 -0
  2. mifit/auth/__init__.py +0 -0
  3. mifit/auth/lifetime.py +140 -0
  4. mifit/auth/qr.py +558 -0
  5. mifit/auth/renew.py +110 -0
  6. mifit/auth/session.py +155 -0
  7. mifit/chunks.py +239 -0
  8. mifit/cli/__init__.py +755 -0
  9. mifit/cli/__main__.py +5 -0
  10. mifit/cli/partial.py +241 -0
  11. mifit/client.py +540 -0
  12. mifit/crypto.py +138 -0
  13. mifit/domains/__init__.py +0 -0
  14. mifit/domains/activity_daily.py +127 -0
  15. mifit/domains/base.py +160 -0
  16. mifit/domains/consumption.py +102 -0
  17. mifit/domains/daily_report.py +141 -0
  18. mifit/domains/days.py +69 -0
  19. mifit/domains/heart_rate.py +138 -0
  20. mifit/domains/heart_rate_daily.py +69 -0
  21. mifit/domains/passthrough.py +136 -0
  22. mifit/domains/profile.py +81 -0
  23. mifit/domains/raw_window.py +81 -0
  24. mifit/domains/sleep.py +332 -0
  25. mifit/domains/spo2.py +75 -0
  26. mifit/domains/spo2_daily.py +69 -0
  27. mifit/domains/steps.py +102 -0
  28. mifit/domains/stress_daily.py +84 -0
  29. mifit/domains/weight.py +79 -0
  30. mifit/domains/workout_detail.py +472 -0
  31. mifit/domains/workouts.py +212 -0
  32. mifit/errors.py +72 -0
  33. mifit/fds/__init__.py +36 -0
  34. mifit/fds/binary.py +137 -0
  35. mifit/fds/cipher.py +74 -0
  36. mifit/fds/download.py +224 -0
  37. mifit/fds/ids.py +76 -0
  38. mifit/fds/layouts.py +273 -0
  39. mifit/fds/urls.py +94 -0
  40. mifit/models/__init__.py +0 -0
  41. mifit/models/activity.py +33 -0
  42. mifit/models/consumption.py +30 -0
  43. mifit/models/heart_rate.py +40 -0
  44. mifit/models/profile.py +32 -0
  45. mifit/models/sleep.py +74 -0
  46. mifit/models/spo2.py +38 -0
  47. mifit/models/steps.py +29 -0
  48. mifit/models/stress.py +35 -0
  49. mifit/models/weight.py +24 -0
  50. mifit/models/workout_detail.py +62 -0
  51. mifit/models/workouts.py +46 -0
  52. mifit/paths.py +30 -0
  53. mifit/py.typed +0 -0
  54. mifit/region.py +280 -0
  55. mifit/transport.py +369 -0
  56. mifit-0.2.0.dist-info/METADATA +397 -0
  57. mifit-0.2.0.dist-info/RECORD +62 -0
  58. mifit-0.2.0.dist-info/WHEEL +5 -0
  59. mifit-0.2.0.dist-info/entry_points.txt +2 -0
  60. mifit-0.2.0.dist-info/licenses/LICENSE +21 -0
  61. mifit-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES +50 -0
  62. 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