mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-07-22 13:40:14 +03:00
feat: add miband bot application
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Mi Band tracker service package."""
|
||||
|
||||
from .config import Settings
|
||||
from .sync import SyncResult, run_sync
|
||||
|
||||
__all__ = ["Settings", "SyncResult", "run_sync"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Telegram bot package."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user