Files
ha-babytracker/custom_components/babytracker/babytracker_api/seed.py
T
arneandClaude Opus 5.5 6466f002e0 Add Baby Tracker client and Home Assistant integration
Python client for the Baby Tracker sync server, reverse engineered from
the Android app (4.53), and a HACS-installable Home Assistant integration
built on it. Each baby gets a log calendar and summary sensors, and actions
add entries that sync back to the group.

Reading has been run against the real server. Uploading and running inside
real Home Assistant have not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:04:39 +00:00

194 lines
7.3 KiB
Python

"""Read the seed database that ``GET /account/seed`` returns.
The seed is a zip holding the app's SQLite database. It is the full account
state at some point in time; transaction logs are replayed on top of it.
"""
from __future__ import annotations
import shutil
import sqlite3
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator, Optional, Union
from .models import TYPE_FIELD, format_date, measure
SEED_DB_NAME = "EasyLog.db"
PathLike = Union[str, Path]
def extract_seed_db(zip_path: PathLike, dest_path: PathLike) -> Path:
"""Extract the database from a seed zip to ``dest_path``.
Copies only the one expected member, so paths inside the archive never
decide where anything is written.
"""
dest = Path(dest_path)
with zipfile.ZipFile(zip_path) as archive:
matches = [name for name in archive.namelist() if Path(name).name == SEED_DB_NAME]
if not matches:
raise ValueError(f"{SEED_DB_NAME} not found in seed zip")
with archive.open(matches[0]) as source, dest.open("wb") as target:
shutil.copyfileobj(source, target)
return dest
def open_seed_db(db_path: PathLike) -> sqlite3.Connection:
"""Open an extracted seed database read-only, with rows addressable by name."""
connection = sqlite3.connect(f"{Path(db_path).resolve().as_uri()}?mode=ro", uri=True)
connection.row_factory = sqlite3.Row
return connection
def _date_field(seconds: Optional[float]) -> Optional[str]:
# Database times are Unix seconds. The app treats values <= 0 as unset.
if not seconds or seconds <= 0:
return None
return format_date(datetime.fromtimestamp(seconds, timezone.utc))
def read_babies(connection: sqlite3.Connection) -> list[dict[str, Any]]:
"""Return babies as records usable in ``baby=`` of the record builders."""
babies = []
for row in connection.execute("SELECT ID, Timestamp, Name, DOB, DueDay, Gender, Picture FROM Baby"):
baby: dict[str, Any] = {
"gender": bool(row["Gender"]), # True is boy.
"name": row["Name"],
"newFlage": False,
"objectID": row["ID"],
}
if row["Picture"]:
baby["pictureName"] = row["Picture"]
for field, value in (("dob", row["DOB"]), ("dueDay", row["DueDay"]), ("timestamp", row["Timestamp"])):
formatted = _date_field(value)
if formatted:
baby[field] = formatted
babies.append(baby)
return babies
def read_cursors(connection: sqlite3.Connection) -> dict[str, int]:
"""Return per-device SyncIDs already merged into the seed.
Passing these to :meth:`BabyTrackerClient.pull` replays only the
transactions that are newer than the seed.
"""
rows = connection.execute("SELECT DeviceID, SyncID FROM MergedTransaction")
return {row["DeviceID"]: row["SyncID"] for row in rows}
# Database tables holding activities, and the record type each corresponds to.
_ACTIVITY_TABLES = {
"Allergen": "Allergen",
"Bath": "Bath",
"Diaper": "Diaper",
"DoctorVisit": "DoctorVisit",
"Formula": "Formula",
"Growth": "Growth",
"HealthQuestion": "HealthQuestion",
"Journal": "Journal",
"Joy": "Joy",
"Medicine": "Medication",
"Milestone": "Milestone",
"Nursing": "Nursing",
"OtherActivity": "OtherActivity",
"OtherFeed": "OtherFeed",
"Pump": "Pump",
"Pumped": "Pumped",
"Sick": "Sick",
"Sleep": "Sleep",
"Temperature": "Temperature",
"Vaccine": "Vaccine",
}
def _names(connection: sqlite3.Connection, table: str) -> dict[str, str]:
try:
return {row["ID"]: row["Name"] for row in connection.execute(f'SELECT ID, Name FROM "{table}"')}
except sqlite3.OperationalError:
return {}
def _optional_measure(value: Optional[float], english: Any) -> Optional[dict[str, Any]]:
# Growth columns default to 0, and the app reads 0 as "not measured".
return measure(value, bool(english)) if value else None
def _activity_fields(table: str, row: dict[str, Any], names: dict[str, dict[str, str]]) -> dict[str, Any]:
"""Convert a table's own columns to the fields its record type uses in the log."""
if table == "Diaper":
return {"status": row.get("Status"), "amount": row.get("Amount")}
if table == "Sleep":
return {"duration": row.get("Duration") or 0}
if table in ("Formula", "Pumped", "Pump"):
return {"amount": measure(row.get("Amount") or 0, bool(row.get("IsEnglishScale")))}
if table == "Nursing":
return {
"finishSide": row.get("FinishSide"),
"leftDuration": row.get("LeftDuration") or 0,
"rightDuration": row.get("RightDuration") or 0,
"bothDuration": row.get("BothDuration") or 0,
}
if table == "OtherFeed":
return {
"feedType": {"name": names["OtherFeedSelection"].get(row.get("TypeID"))},
"amount": measure(row.get("Amount") or 0, bool(row.get("IsEnglishScale"))),
"unit": row.get("Unit"),
}
if table == "Temperature":
return {"temperature": measure(row.get("Temp") or 0, bool(row.get("IsEnglishScale")))}
if table == "Growth":
# Length and head share one scale column.
return {
"weight": _optional_measure(row.get("Weight"), row.get("IsEnglishWeightScale")),
"length": _optional_measure(row.get("Length"), row.get("IsEnglishLengthScale")),
"head": _optional_measure(row.get("Head"), row.get("IsEnglishLengthScale")),
}
if table == "Medicine":
return {
"medicationSelection": {"name": names["MedicineSelection"].get(row.get("MedID"))},
"amount": row.get("Amount"),
}
if table == "Vaccine":
return {"vaccineType": {"name": names["VaccineSelection"].get(row.get("VaccID"))}}
if table == "Milestone":
return {"milestoneType": {"name": names["MilestoneSelection"].get(row.get("MilestoneSelectionID"))}}
if table == "OtherActivity":
return {
"desc": {"name": names["OtherActivityDesc"].get(row.get("DescID"))},
"duration": row.get("Duration") or 0,
}
return {}
def read_records(connection: sqlite3.Connection) -> Iterator[dict[str, Any]]:
"""Yield the seed's activities, shaped like the records in transaction logs.
Tables missing from an older or newer database are skipped.
"""
names = {
table: _names(connection, table)
for table in ("MedicineSelection", "MilestoneSelection", "OtherActivityDesc", "OtherFeedSelection", "VaccineSelection")
}
for table, record_type in _ACTIVITY_TABLES.items():
try:
rows = connection.execute(f'SELECT * FROM "{table}"').fetchall()
except sqlite3.OperationalError:
continue
for sqlite_row in rows:
row = dict(sqlite_row)
record: dict[str, Any] = {
TYPE_FIELD: record_type,
"objectID": row["ID"],
"time": datetime.fromtimestamp(row["Time"], timezone.utc),
"timestamp": _date_field(row.get("Timestamp")),
"note": row.get("Note") or "",
}
if row.get("BabyID"):
record["baby"] = {"objectID": row["BabyID"]}
record.update(_activity_fields(table, row, names))
yield record