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>
This commit is contained in:
commit
6466f002e0
51 files changed
+5199
No files matched your search
@@ -0,0 +1,193 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user