feat: add miband bot application

This commit is contained in:
Alex
2026-05-24 21:53:04 +03:00
parent 23876f70e2
commit 7962c423b4
71 changed files with 12533 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
"""Mi Band tracker service package."""
from .config import Settings
from .sync import SyncResult, run_sync
__all__ = ["Settings", "SyncResult", "run_sync"]
+1
View File
@@ -0,0 +1 @@
"""Telegram bot package."""
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
class ConfigError(ValueError):
"""Invalid runtime configuration."""
def _env_bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int, *, min_value: int | None = None) -> int:
raw = os.environ.get(name, str(default)).strip()
try:
value = int(raw)
except ValueError as exc:
raise ConfigError(f"{name} должен быть целым числом") from exc
if min_value is not None and value < min_value:
raise ConfigError(f"{name} должен быть не меньше {min_value}")
return value
@dataclass(frozen=True)
class Settings:
data_dir: Path
db_path: Path
status_path: Path
bot_state_db_path: Path
telegram_bot_token: str
telegram_allowed_user_id: int | None
sync_interval: int
query_duration: int
enable_fds_sleep_details: bool
@classmethod
def from_env(cls, *, require_bot: bool = False) -> "Settings":
data_dir = Path(os.environ.get("DATA_DIR", "/opt/miband-tracker/data"))
allowed_user_id = parse_single_user_id(
os.environ.get("TELEGRAM_ALLOWED_USER_ID", ""), required=require_bot
)
bot_token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
if require_bot and not bot_token:
raise ConfigError("TELEGRAM_BOT_TOKEN не задан")
return cls(
data_dir=data_dir,
db_path=Path(os.environ.get("DB_PATH", str(data_dir / "miband.db"))),
status_path=Path(os.environ.get("STATUS_PATH", str(data_dir / "status.json"))),
bot_state_db_path=Path(
os.environ.get(
"BOT_STATE_DB_PATH",
str(data_dir / "fitness_bot_state.db"),
)
),
telegram_bot_token=bot_token,
telegram_allowed_user_id=allowed_user_id,
sync_interval=_env_int("SYNC_INTERVAL", 900, min_value=0),
query_duration=_env_int("QUERY_DURATION", 2, min_value=1),
enable_fds_sleep_details=_env_bool("ENABLE_FDS_SLEEP_DETAILS", default=True),
)
def require_user_id(self, user_id: int | None = None) -> int:
resolved = user_id if user_id is not None else self.telegram_allowed_user_id
if resolved is None:
raise ConfigError("TELEGRAM_ALLOWED_USER_ID должен содержать ровно один user id")
return int(resolved)
def token_path(self, user_id: int | None = None) -> Path:
uid = self.require_user_id(user_id)
preferred = self.data_dir / f"token_{uid}.json"
legacy = self.data_dir / "token.json"
if preferred.exists() or not legacy.exists():
return preferred
return legacy
def user_db_path(self, user_id: int | None = None) -> Path:
uid = self.require_user_id(user_id)
preferred = self.data_dir / f"miband_{uid}.db"
if preferred.exists():
return preferred
return self.db_path
def user_status_path(self, user_id: int | None = None) -> Path:
uid = self.require_user_id(user_id)
preferred = self.data_dir / f"status_{uid}.json"
if preferred.exists():
return preferred
return self.status_path
def canonical_user_db_path(self, user_id: int | None = None) -> Path:
return self.data_dir / f"miband_{self.require_user_id(user_id)}.db"
def canonical_user_status_path(self, user_id: int | None = None) -> Path:
return self.data_dir / f"status_{self.require_user_id(user_id)}.json"
def parse_single_user_id(raw: str, *, required: bool = False) -> int | None:
values = [item.strip() for item in raw.split(",") if item.strip()]
if not values:
if required:
raise ConfigError("TELEGRAM_ALLOWED_USER_ID не задан или пуст")
return None
if len(values) > 1:
raise ConfigError("TELEGRAM_ALLOWED_USER_ID должен содержать ровно один user id")
try:
return int(values[0])
except ValueError as exc:
raise ConfigError("TELEGRAM_ALLOWED_USER_ID должен быть целым числом") from exc
+286
View File
@@ -0,0 +1,286 @@
from __future__ import annotations
import base64
import hashlib
import struct
import httpx
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
def normalize_timezone_to_15min(timezone_value: int) -> int:
# Xiaomi sleep segments already use 15-minute units; token/bootstrap paths may use seconds.
if abs(timezone_value) <= 96:
return int(timezone_value)
return int(timezone_value / 900)
def gen_data_id_key_bytes(timestamp: int, tz_in_15min: int, daily_type: int, file_type: int, data_type: int = 0, sport_type: int = 0) -> bytes:
data_type_byte = (data_type << 7) + (sport_type << 2) + (daily_type << 2) + file_type
return struct.pack("<IbB", timestamp, tz_in_15min, data_type_byte)
def parse_sleep_assist_info(b, pos, byte_count, is_float, is_unsigned, version):
if pos + 4 > len(b):
return None, pos
interval = struct.unpack_from("<h", b, pos)[0]
record_count = struct.unpack_from("<h", b, pos + 2)[0]
pos += 4
if record_count <= 0:
return None, pos
actual_byte_count = byte_count * record_count
if version >= 2:
actual_byte_count += 4
if pos + actual_byte_count > len(b):
return None, pos
start_time = 0
if version >= 2:
start_time = struct.unpack_from("<I", b, pos)[0]
pos += 4
values = []
for _ in range(record_count):
if byte_count == 1:
val = b[pos]
pos += 1
elif byte_count == 2:
val = struct.unpack_from("<H" if is_unsigned else "<h", b, pos)[0]
pos += 2
elif byte_count == 4:
if is_float:
val = struct.unpack_from("<f", b, pos)[0]
else:
val = struct.unpack_from("<I" if is_unsigned else "<i", b, pos)[0]
pos += 4
else:
val = b[pos:pos+byte_count]
pos += byte_count
values.append(val)
return {
"start_time": start_time,
"interval": interval,
"record_count": record_count,
"values": values
}, pos
def parse_all_day_sleep_bytes(b: bytes):
if len(b) < 9:
return None
try:
return _parse_all_day_sleep_bytes(b)
except (IndexError, struct.error):
return None
def _parse_all_day_sleep_bytes(b: bytes):
_ = struct.unpack_from("<I", b, 0)[0]
_ = b[4]
version = b[5]
_ = b[6]
data_valid = b[7:9]
valid_map = {}
i = 0
types_to_check = [0, 1, 2, 6, 7, 8, 9, 10, 3, 4, 5]
for t in types_to_check:
byte_idx = i // 8
bit_idx = i % 8
val = (data_valid[byte_idx] & (1 << (7 - bit_idx))) > 0
valid_map[t] = val
i += 1
pos = 9
report_data = {
"sleepFinish": bool(b[pos] == 1)
}
pos += 1
# type=0 (deviceBedTime)
report_data["deviceBedTime"] = struct.unpack_from("<I", b, pos)[0]
pos += 4
# type=1 (deviceWakeupTime)
report_data["deviceWakeupTime"] = struct.unpack_from("<I", b, pos)[0]
pos += 4
# type=2 (sleepQuality)
val = b[pos]
if valid_map[2]:
report_data["sleepQuality"] = val
pos += 1
# type=6 (sleepEfficiency)
val = b[pos]
if valid_map[6]:
report_data["sleepEfficiency"] = val
pos += 1
# type=7 (entrySleepDuration)
val = struct.unpack_from("<I", b, pos)[0]
if valid_map[7]:
report_data["entrySleepDuration"] = val
pos += 4
# type=8 (linBedDuration)
val = struct.unpack_from("<I", b, pos)[0]
if valid_map[8]:
report_data["linBedDuration"] = val
pos += 4
# type=9 (goBedTime)
val = struct.unpack_from("<I", b, pos)[0]
if valid_map[9]:
report_data["goBedTime"] = val
pos += 4
# type=10 (leaveBedTime)
val = struct.unpack_from("<I", b, pos)[0]
if valid_map[10]:
report_data["leaveBedTime"] = val
pos += 4
records = {
"heart_rate": [],
"spo2": []
}
# type 3: hr
if valid_map[3]:
hr_data, pos = parse_sleep_assist_info(b, pos, 1, False, False, version)
if hr_data:
start_t = hr_data["start_time"]
interval = hr_data["interval"]
for idx, val in enumerate(hr_data["values"]):
if val > 0 and val < 255:
records["heart_rate"].append((start_t + idx * interval, val))
# type 4: spo2
if valid_map[4]:
spo2_data, pos = parse_sleep_assist_info(b, pos, 1, False, False, version)
if spo2_data:
start_t = spo2_data["start_time"]
interval = spo2_data["interval"]
for idx, val in enumerate(spo2_data["values"]):
if val > 0 and val <= 100:
records["spo2"].append((start_t + idx * interval, val))
return {
"report": report_data,
"records": records
}
async def download_and_decrypt_sleep_details(client, relative_uid: int, timestamp: int, timezone_value: int, log_fn=print):
tz_in_15min = normalize_timezone_to_15min(timezone_value)
# 1. Generate FDSItem
sid = str(relative_uid)
key_bytes = gen_data_id_key_bytes(timestamp, tz_in_15min, daily_type=8, file_type=0)
suffix_b64 = base64.urlsafe_b64encode(key_bytes).decode().rstrip("=")
sha1_sid = hashlib.sha1(sid.encode()).digest()
sha1_b64 = base64.urlsafe_b64encode(sha1_sid).decode().rstrip("=")
suffix = f"{suffix_b64}_{sha1_b64}"
# 2. Prepare request params
param_dict = {
"did": sid,
"relative_uid": relative_uid,
"items": [
{
"timestamp": timestamp,
"suffix": suffix
}
]
}
# 3. Call service/gen_download_url
resp = await client._request(
"GET",
"/healthapp/service/gen_download_url",
params=param_dict
)
result = resp.get("result", {})
log_fn(
"gen_download_url returned "
f"code={resp.get('code')} message={resp.get('message')} "
f"result_keys_count={len(result)}"
)
server_key = f"{suffix}_{timestamp}"
file_info = result.get(server_key)
if not file_info:
log_fn("No FDS info found for requested sleep segment.")
return None
url = file_info.get("url")
obj_key_b64 = file_info.get("obj_key")
if not url:
log_fn("FDS info missing download URL.")
return None
# 4. Download file
async with httpx.AsyncClient(timeout=30.0) as http_client:
file_resp = await http_client.get(url)
if file_resp.status_code != 200:
log_fn(f"Optional FDS sleep detail unavailable: HTTP {file_resp.status_code}; skipping.")
return None
enc_content = file_resp.content
log_fn(f"Downloaded FDS content length: {len(enc_content)}")
# 5. Decrypt or decompress content
def android_base64_urlsafe(s):
if isinstance(s, bytes):
s = s.decode("utf-8", "ignore")
s = s.strip().replace("\n", "").replace("\r", "")
s += "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
if obj_key_b64:
try:
encrypted_bytes = android_base64_urlsafe(enc_content)
obj_key_bytes = android_base64_urlsafe(obj_key_b64)
if len(obj_key_bytes) != 16:
log_fn(f"Invalid obj_key length: {len(obj_key_bytes)}")
return None
cipher = AES.new(obj_key_bytes, AES.MODE_CBC, b"1234567887654321")
decrypted = cipher.decrypt(encrypted_bytes)
decrypted = unpad(decrypted, 16)
return decrypted
except Exception as e:
log_fn(f"AES decryption or unpadding failed: {e}")
return None
else:
log_fn("No obj_key in file_info. Checking if content is compressed (gzip/zlib) or raw...")
# Check for GZIP header (1f 8b)
if enc_content.startswith(b"\x1f\x8b"):
try:
import gzip
decompressed = gzip.decompress(enc_content)
log_fn(f"Successfully decompressed GZIP FDS content. Length: {len(decompressed)}")
return decompressed
except Exception as e:
log_fn(f"Failed to decompress GZIP FDS content: {e}")
# Check for ZLIB header (78 9c or 78 01 or 78 5e etc.)
elif enc_content.startswith(b"\x78\x9c") or enc_content.startswith(b"\x78\x01"):
try:
import zlib
decompressed = zlib.decompress(enc_content)
log_fn(f"Successfully decompressed ZLIB FDS content. Length: {len(decompressed)}")
return decompressed
except Exception as e:
log_fn(f"Failed to decompress ZLIB FDS content: {e}")
# If not compressed, return as is
return enc_content
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import fcntl
import os
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
class LockUnavailable(RuntimeError):
"""Raised when another process already owns the sync lock."""
@contextmanager
def exclusive_file_lock(path: Path) -> Iterator[None]:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a+", encoding="utf-8") as lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise LockUnavailable(f"Lock is already held: {path}") from exc
try:
lock_file.seek(0)
lock_file.truncate()
lock_file.write(f"pid={os.getpid()} time={int(time.time())}\n")
lock_file.flush()
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
SECRET_FILE_MODE = 0o600
def write_text_atomic(path: Path, text: str, *, mode: int | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
fh.flush()
os.fsync(fh.fileno())
if mode is not None:
os.chmod(tmp_path, mode)
os.replace(tmp_path, path)
if mode is not None:
os.chmod(path, mode)
finally:
try:
tmp_path.unlink()
except FileNotFoundError:
pass
def write_json_atomic(path: Path, data: Any, *, mode: int | None = None) -> None:
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
write_text_atomic(path, text, mode=mode)
def write_secret_json(path: Path, data: dict[str, Any]) -> None:
write_json_atomic(path, data, mode=SECRET_FILE_MODE)
def save_auth_token(token: Any, path: Path) -> None:
current = _read_current_token_payload(path)
if hasattr(token, "model_dump"):
payload = token.model_dump()
elif hasattr(token, "model_dump_json"):
payload = json.loads(token.model_dump_json())
else:
payload = dict(token)
for key in ("target_relative_uid",):
if key in current and key not in payload:
payload[key] = current[key]
write_secret_json(path, payload)
def _read_current_token_payload(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return payload if isinstance(payload, dict) else {}
+208
View File
@@ -0,0 +1,208 @@
from __future__ import annotations
import csv
import io
import json
import sqlite3
import zipfile
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from .config import Settings
LOCAL_TZ = ZoneInfo("Europe/Moscow")
EXPORT_TABLES = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen", "stress"]
@contextmanager
def sqlite_conn(path: Path, *, row_factory: bool = True):
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.execute("PRAGMA busy_timeout = 5000")
if row_factory:
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def init_health_db(db_path: Path) -> None:
with sqlite_conn(db_path, row_factory=False) as conn:
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS steps_daily (
date TEXT PRIMARY KEY,
total_steps INTEGER,
calories REAL,
distance_m REAL,
last_sync INTEGER
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS steps_detail (
timestamp INTEGER PRIMARY KEY,
steps INTEGER,
calories REAL,
distance_m REAL,
activity_type TEXT
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS sleep_daily (
date TEXT PRIMARY KEY,
light_sleep_min INTEGER,
deep_sleep_min INTEGER,
start_time INTEGER,
end_time INTEGER,
rem_sleep_min INTEGER DEFAULT 0,
awake_min INTEGER DEFAULT 0,
total_duration_min INTEGER DEFAULT 0,
sleep_score INTEGER DEFAULT 0
)
"""
)
_ensure_columns(cursor, "sleep_daily", {
"rem_sleep_min": "INTEGER DEFAULT 0",
"awake_min": "INTEGER DEFAULT 0",
"total_duration_min": "INTEGER DEFAULT 0",
"sleep_score": "INTEGER DEFAULT 0",
})
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS sleep_stages (
start_time INTEGER PRIMARY KEY,
stop_time INTEGER,
stage TEXT,
duration_min INTEGER
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS heart_rate (
timestamp INTEGER PRIMARY KEY,
value INTEGER
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS stress (
timestamp INTEGER PRIMARY KEY,
value INTEGER
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS blood_oxygen (
timestamp INTEGER PRIMARY KEY,
spo2 REAL,
type TEXT
)
"""
)
conn.commit()
def _ensure_columns(cursor: sqlite3.Cursor, table: str, columns: dict[str, str]) -> None:
existing = {row[1] for row in cursor.execute(f"PRAGMA table_info({table})").fetchall()}
for name, definition in columns.items():
if name not in existing:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
def init_state_db(settings: Settings) -> None:
with sqlite_conn(settings.bot_state_db_path, row_factory=False) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_menu (
user_id INTEGER PRIMARY KEY,
menu_message_id INTEGER NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.commit()
def get_user_menu_msg_id(settings: Settings, user_id: int) -> int | None:
try:
with sqlite_conn(settings.bot_state_db_path, row_factory=False) as conn:
row = conn.execute(
"SELECT menu_message_id FROM user_menu WHERE user_id = ?",
(user_id,),
).fetchone()
return int(row[0]) if row else None
except sqlite3.Error:
return None
def set_user_menu_msg_id(settings: Settings, user_id: int, msg_id: int) -> None:
with sqlite_conn(settings.bot_state_db_path, row_factory=False) as conn:
conn.execute(
"""
INSERT OR REPLACE INTO user_menu (user_id, menu_message_id, updated_at)
VALUES (?, ?, ?)
""",
(user_id, msg_id, datetime.now(LOCAL_TZ).isoformat(timespec="seconds")),
)
conn.commit()
def health_db_exists(settings: Settings, user_id: int | None = None) -> bool:
return settings.user_db_path(user_id).exists()
def fetch_one(settings: Settings, query: str, params: tuple = (), user_id: int | None = None) -> sqlite3.Row | None:
if not health_db_exists(settings, user_id):
return None
with sqlite_conn(settings.user_db_path(user_id)) as conn:
return conn.execute(query, params).fetchone()
def fetch_all(settings: Settings, query: str, params: tuple = (), user_id: int | None = None) -> list[sqlite3.Row]:
if not health_db_exists(settings, user_id):
return []
with sqlite_conn(settings.user_db_path(user_id)) as conn:
return conn.execute(query, params).fetchall()
def read_status_file(settings: Settings, user_id: int | None = None) -> dict:
try:
return json.loads(settings.user_status_path(user_id).read_text(encoding="utf-8"))
except Exception:
return {}
def zip_export(settings: Settings, user_id: int | None = None) -> io.BytesIO:
bio = io.BytesIO()
with sqlite_conn(settings.user_db_path(user_id)) as conn:
with zipfile.ZipFile(bio, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for table in EXPORT_TABLES:
exists = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone()
if not exists:
continue
cursor = conn.execute(f"SELECT * FROM {table} ORDER BY 1")
rows = cursor.fetchall()
if not rows:
continue
csv_text = io.StringIO()
writer = csv.writer(csv_text)
writer.writerow([desc[0] for desc in cursor.description])
writer.writerows([tuple(row) for row in rows])
archive.writestr(f"{table}.csv", csv_text.getvalue())
bio.seek(0)
bio.name = f"miband-health-{datetime.now(LOCAL_TZ).strftime('%Y%m%d-%H%M')}.zip"
return bio
+375
View File
@@ -0,0 +1,375 @@
from __future__ import annotations
import asyncio
import datetime
import json
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from mi_fitness import MiHealthClient, TokenExpiredError
from .config import ConfigError, Settings
from .fds import download_and_decrypt_sleep_details, parse_all_day_sleep_bytes
from .lock import LockUnavailable, exclusive_file_lock
from .secure_files import save_auth_token, write_json_atomic, write_secret_json
from .storage import init_health_db, sqlite_conn
@dataclass
class SyncResult:
success: bool
user_id: int | None = None
counters: dict[str, int] = field(default_factory=dict)
error: str | None = None
@classmethod
def failed(cls, message: str, *, user_id: int | None = None) -> "SyncResult":
return cls(False, user_id=user_id, error=message)
def log(message: str) -> None:
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{now}] {message}", flush=True)
def format_epoch(epoch: int | float | None) -> str | None:
if not epoch:
return None
return datetime.datetime.fromtimestamp(int(epoch)).strftime("%Y-%m-%d %H:%M:%S")
async def run_sync(
user_id: int | None = None,
settings: Settings | None = None,
) -> SyncResult:
settings = settings or Settings.from_env()
try:
resolved_user_id = settings.require_user_id(user_id)
except ConfigError as exc:
log(str(exc))
return SyncResult.failed(str(exc), user_id=user_id)
lock_path = settings.data_dir / f"sync_{resolved_user_id}.lock"
try:
with exclusive_file_lock(lock_path):
return await _run_sync_locked(resolved_user_id, settings)
except LockUnavailable:
message = "Sync is already running for this user"
log(message)
return SyncResult.failed(message, user_id=resolved_user_id)
async def _run_sync_locked(resolved_user_id: int, settings: Settings) -> SyncResult:
token_path = settings.token_path(resolved_user_id)
if not token_path.exists() and os.getenv("SSECURITY"):
_bootstrap_token_from_env(token_path)
if not token_path.exists():
message = f"Token file not found at: {token_path}"
log(message)
return SyncResult.failed(message, user_id=resolved_user_id)
target_relative_uid = _target_relative_uid(token_path)
if not target_relative_uid:
message = f"No TARGET_RELATIVE_UID, target_relative_uid or user_id found in {token_path.name}"
log(message)
return SyncResult.failed(message, user_id=resolved_user_id)
db_path = settings.canonical_user_db_path(resolved_user_id)
status_path = settings.canonical_user_status_path(resolved_user_id)
return await run_sync_for_user(
token_path=token_path,
db_path=db_path,
status_path=status_path,
target_relative_uid=target_relative_uid,
user_id=resolved_user_id,
settings=settings,
)
def _bootstrap_token_from_env(token_path: Path) -> None:
log(f"Token file not found. Auto-generating {token_path} from environment variables...")
token_path.parent.mkdir(parents=True, exist_ok=True)
token_data = {
"user_id": os.getenv("USER_ID", ""),
"c_user_id": os.getenv("C_USER_ID", ""),
"service_token": os.getenv("SERVICE_TOKEN", ""),
"ssecurity": os.getenv("SSECURITY", ""),
"pass_token": os.getenv("PASS_TOKEN", ""),
"device_id": os.getenv("DEVICE_ID", f"an_{os.urandom(16).hex()}"),
"target_relative_uid": os.getenv("TARGET_RELATIVE_UID", ""),
}
write_secret_json(token_path, token_data)
def _target_relative_uid(token_path: Path) -> str:
try:
token_data = json.loads(token_path.read_text(encoding="utf-8"))
except Exception as exc:
log(f"Failed to read token file {token_path.name}: {exc}")
return ""
return (
str(token_data.get("target_relative_uid", "")).strip()
or os.getenv("TARGET_RELATIVE_UID", "").strip()
or str(token_data.get("user_id", "")).strip()
)
async def run_sync_for_user(
*,
token_path: Path,
db_path: Path,
status_path: Path,
target_relative_uid: str,
user_id: int,
settings: Settings,
) -> SyncResult:
try:
relative_uid = int(target_relative_uid)
except ValueError:
message = "TARGET_RELATIVE_UID must be a valid integer UID"
log(message)
return SyncResult.failed(message, user_id=user_id)
counters = {
"steps_daily": 0,
"sleep_daily": 0,
"sleep_stages": 0,
"heart_rate": 0,
"blood_oxygen": 0,
}
log(
f"Starting sync. Target UID: {relative_uid}. Query duration: {settings.query_duration} days. "
f"DB: {db_path}. FDS sleep details: {'enabled' if settings.enable_fds_sleep_details else 'disabled'}"
)
init_health_db(db_path)
latest_heart_rate = None
latest_steps = None
latest_sleep = None
try:
with sqlite_conn(db_path, row_factory=False) as conn:
cursor = conn.cursor()
async with MiHealthClient.from_token(str(token_path)) as client:
from mi_fitness.auth.sts import sts_exchange
log("Forcing STS exchange with clientSign to obtain a full serviceToken...")
await sts_exchange(client.auth._ensure_http(), client.auth.token)
save_auth_token(client.auth.token, token_path)
steps_list = await client.get_steps(relative_uid, days=settings.query_duration)
for item in steps_list:
if not item.at:
continue
date_str = item.at.date().isoformat()
cursor.execute(
"""
INSERT OR REPLACE INTO steps_daily (date, total_steps, calories, distance_m, last_sync)
VALUES (?, ?, ?, ?, ?)
""",
(date_str, item.steps, float(item.calories), float(item.distance), int(time.time())),
)
counters["steps_daily"] += 1
if not latest_steps or date_str >= latest_steps["date"]:
latest_steps = {
"date": date_str,
"total_steps": item.steps,
"calories": float(item.calories),
"distance_m": float(item.distance),
"last_sync": int(time.time()),
}
sleep_list = await client.get_sleep(relative_uid, days=settings.query_duration)
for sleep in sleep_list:
if not sleep.at:
continue
date_str = sleep.at.date().isoformat()
start_time = 0
end_time = 0
if sleep.segment_details:
start_time = sleep.segment_details[0].bedtime
end_time = sleep.segment_details[-1].wake_up_time
for segment in sleep.segment_details:
cursor.execute(
"""
INSERT OR REPLACE INTO sleep_stages (start_time, stop_time, stage, duration_min)
VALUES (?, ?, ?, ?)
""",
(segment.bedtime, segment.wake_up_time, "sleep_segment", segment.duration),
)
counters["sleep_stages"] += 1
if settings.enable_fds_sleep_details:
await _sync_fds_segment(cursor, counters, client, relative_uid, segment)
cursor.execute(
"""
INSERT OR REPLACE INTO sleep_daily
(date, light_sleep_min, deep_sleep_min, rem_sleep_min, awake_min,
total_duration_min, sleep_score, start_time, end_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
date_str,
sleep.sleep_light_duration,
sleep.sleep_deep_duration,
getattr(sleep, "sleep_rem_duration", 0) or 0,
getattr(sleep, "sleep_awake_duration", 0) or 0,
getattr(sleep, "total_duration", 0) or 0,
getattr(sleep, "sleep_score", 0) or 0,
start_time,
end_time,
),
)
counters["sleep_daily"] += 1
if not latest_sleep or date_str >= latest_sleep["date"]:
latest_sleep = {
"date": date_str,
"light_sleep_min": sleep.sleep_light_duration,
"deep_sleep_min": sleep.sleep_deep_duration,
"rem_sleep_min": getattr(sleep, "sleep_rem_duration", 0) or 0,
"awake_min": getattr(sleep, "sleep_awake_duration", 0) or 0,
"total_duration_min": getattr(sleep, "total_duration", 0) or 0,
"sleep_score": getattr(sleep, "sleep_score", 0) or 0,
"start_time": start_time,
"end_time": end_time,
}
hr_list = await client.get_heart_rate(relative_uid, days=settings.query_duration)
for hr in hr_list:
cursor.execute(
"INSERT OR IGNORE INTO heart_rate (timestamp, value) VALUES (?, ?)",
(hr.time, hr.avg_hr),
)
counters["heart_rate"] += cursor.rowcount > 0
if hr.latest_hr:
cursor.execute(
"INSERT OR IGNORE INTO heart_rate (timestamp, value) VALUES (?, ?)",
(hr.latest_hr.time, hr.latest_hr.bpm),
)
counters["heart_rate"] += cursor.rowcount > 0
if not latest_heart_rate or hr.latest_hr.time > latest_heart_rate["timestamp"]:
latest_heart_rate = {"timestamp": hr.latest_hr.time, "value": hr.latest_hr.bpm}
try:
spo2_list = await client.get_spo2_history(relative_uid, days=settings.query_duration)
for spo2 in spo2_list:
cursor.execute(
"INSERT OR IGNORE INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, ?)",
(spo2.time, float(spo2.avg_spo2), "daily_avg"),
)
counters["blood_oxygen"] += cursor.rowcount > 0
if spo2.latest_spo2:
cursor.execute(
"INSERT OR IGNORE INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, ?)",
(spo2.latest_spo2.time, float(spo2.latest_spo2.spo2), "latest"),
)
counters["blood_oxygen"] += cursor.rowcount > 0
except Exception as exc:
log(f"Failed to fetch blood oxygen: {exc}")
conn.commit()
except TokenExpiredError:
message = "Token has expired and auto-refresh failed. Action required: re-login."
log(message)
return SyncResult.failed(message, user_id=user_id)
except Exception as exc:
message = f"API request failed: {exc}"
log(message)
return SyncResult.failed(message, user_id=user_id)
_write_status_file(status_path, latest_steps, latest_heart_rate, latest_sleep)
log("Sync completed successfully.")
return SyncResult(True, user_id=user_id, counters=counters)
async def _sync_fds_segment(cursor, counters: dict[str, int], client, relative_uid: int, segment) -> None:
try:
log(f"Requesting FDS sleep details for wake_up_time {segment.wake_up_time} ({format_epoch(segment.wake_up_time)})...")
bin_data = await download_and_decrypt_sleep_details(
client,
relative_uid,
segment.wake_up_time,
segment.timezone,
log_fn=log,
)
if not bin_data:
return
parsed = parse_all_day_sleep_bytes(bin_data)
if not parsed:
return
log(
f"Parsed {len(parsed['records']['heart_rate'])} HR readings and "
f"{len(parsed['records']['spo2'])} SpO2 readings from FDS."
)
for timestamp, value in parsed["records"]["heart_rate"]:
cursor.execute(
"INSERT OR REPLACE INTO heart_rate (timestamp, value) VALUES (?, ?)",
(timestamp, value),
)
counters["heart_rate"] += 1
for timestamp, value in parsed["records"]["spo2"]:
cursor.execute(
"INSERT OR REPLACE INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, ?)",
(timestamp, float(value), "fds_detail"),
)
counters["blood_oxygen"] += 1
except Exception as exc:
log(f"Failed to sync details from FDS: {exc}")
def _write_status_file(status_path: Path, latest_steps, latest_heart_rate, latest_sleep) -> None:
now_epoch = int(time.time())
status_data = {
"last_sync": now_epoch,
"last_sync_time": format_epoch(now_epoch),
"today": None,
"latest_heart_rate": None,
"latest_sleep": None,
}
if latest_steps:
status_data["today"] = {
"date": latest_steps["date"],
"steps": latest_steps["total_steps"],
"calories": latest_steps["calories"],
"distance_m": latest_steps["distance_m"],
}
if latest_heart_rate:
status_data["latest_heart_rate"] = {
"timestamp": latest_heart_rate["timestamp"],
"time": format_epoch(latest_heart_rate["timestamp"]),
"value": latest_heart_rate["value"],
}
if latest_sleep:
status_data["latest_sleep"] = {
"date": latest_sleep["date"],
"light_sleep_min": latest_sleep["light_sleep_min"],
"deep_sleep_min": latest_sleep["deep_sleep_min"],
"rem_sleep_min": latest_sleep["rem_sleep_min"],
"awake_min": latest_sleep["awake_min"],
"total_sleep_min": latest_sleep["total_duration_min"]
or latest_sleep["light_sleep_min"] + latest_sleep["deep_sleep_min"],
"sleep_score": latest_sleep["sleep_score"],
"start_time": format_epoch(latest_sleep["start_time"]),
"end_time": format_epoch(latest_sleep["end_time"]),
}
write_json_atomic(status_path, status_data)
log(f"Status file written to {status_path}")
async def daemon_main(settings: Settings | None = None) -> int:
settings = settings or Settings.from_env()
if settings.sync_interval <= 0:
log("Running in one-shot mode.")
result = await run_sync(settings=settings)
return 0 if result.success else 1
log(f"Running in daemon mode. Sync interval: {settings.sync_interval} seconds.")
while True:
try:
await run_sync(settings=settings)
except Exception as exc:
log(f"Unhandled error in main loop: {exc}")
log(f"Sleeping for {settings.sync_interval} seconds...")
await asyncio.sleep(settings.sync_interval)