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,263 @@
|
||||
"""A local copy of an account's data, built from the seed and transaction logs.
|
||||
|
||||
The server offers no way to query records, so anything that wants to show them
|
||||
keeps its own copy. Records are normalized here to metric units and readable
|
||||
names. The original record of a baby is kept because new activities nest it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Collection, Iterable, Mapping, Optional, Union
|
||||
|
||||
from .models import TYPE_FIELD, DiaperAmount, DiaperStatus, NursingFinishSide, OpCode, parse_date
|
||||
from .seed import read_babies, read_records
|
||||
|
||||
BOTTLE_KINDS = frozenset({"Formula", "Pumped"})
|
||||
FEED_KINDS = BOTTLE_KINDS | {"Nursing", "OtherFeed"}
|
||||
|
||||
_ML_PER_FL_OZ = 29.5735
|
||||
_KG_PER_LB = 0.45359237
|
||||
_CM_PER_IN = 2.54
|
||||
|
||||
_LABELS = {
|
||||
"DoctorVisit": "Doctor visit",
|
||||
"HealthQuestion": "Health question",
|
||||
"OtherActivity": "Activity",
|
||||
"OtherFeed": "Feed",
|
||||
"Pumped": "Pumped milk",
|
||||
}
|
||||
|
||||
|
||||
def _date(value: Union[datetime, str, None]) -> Optional[datetime]:
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
return parse_date(value)
|
||||
|
||||
|
||||
def _measure(measure: Optional[Mapping[str, Any]], english_factor: float) -> Optional[float]:
|
||||
if not measure or measure.get("value") is None:
|
||||
return None
|
||||
value = float(measure["value"])
|
||||
return value * english_factor if measure.get("englishMeasure") else value
|
||||
|
||||
|
||||
def _celsius(measure: Optional[Mapping[str, Any]]) -> Optional[float]:
|
||||
if not measure or measure.get("value") is None:
|
||||
return None
|
||||
value = float(measure["value"])
|
||||
return (value - 32) * 5 / 9 if measure.get("englishMeasure") else value
|
||||
|
||||
|
||||
def _name(nested: Optional[Mapping[str, Any]]) -> Optional[str]:
|
||||
return nested.get("name") if nested else None
|
||||
|
||||
|
||||
def _enum_name(enum: Any, value: Any) -> Optional[str]:
|
||||
try:
|
||||
return enum(int(value)).name.lower()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _diaper_details(record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
status = _enum_name(DiaperStatus, record.get("status"))
|
||||
amount = _enum_name(DiaperAmount, record.get("amount"))
|
||||
# The app's statistics query counts wet with amount none as dry.
|
||||
if status == "wet" and amount == "none":
|
||||
status = "dry"
|
||||
if amount in ("none", "noset"):
|
||||
amount = None
|
||||
return {"status": status, "amount": amount}
|
||||
|
||||
|
||||
def _nursing_details(record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
left = int(record.get("leftDuration") or 0)
|
||||
right = int(record.get("rightDuration") or 0)
|
||||
both = int(record.get("bothDuration") or 0)
|
||||
side = _enum_name(NursingFinishSide, record.get("finishSide"))
|
||||
return {
|
||||
"left_minutes": left,
|
||||
"right_minutes": right,
|
||||
"both_minutes": both,
|
||||
"duration": left + right + both,
|
||||
"finish_side": None if side == "not_set" else side,
|
||||
}
|
||||
|
||||
|
||||
def _details(kind: str, record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if kind == "Diaper":
|
||||
return _diaper_details(record)
|
||||
if kind == "Sleep":
|
||||
return {"duration": int(record.get("duration") or 0)}
|
||||
if kind in ("Formula", "Pumped", "Pump"):
|
||||
return {"volume_ml": _measure(record.get("amount"), _ML_PER_FL_OZ)}
|
||||
if kind == "Nursing":
|
||||
return _nursing_details(record)
|
||||
if kind == "OtherFeed":
|
||||
amount = record.get("amount") or {}
|
||||
return {"name": _name(record.get("feedType")), "amount": amount.get("value"), "unit": record.get("unit")}
|
||||
if kind == "Temperature":
|
||||
return {"celsius": _celsius(record.get("temperature"))}
|
||||
if kind == "Growth":
|
||||
return {
|
||||
"weight_kg": _measure(record.get("weight"), _KG_PER_LB),
|
||||
"length_cm": _measure(record.get("length"), _CM_PER_IN),
|
||||
"head_cm": _measure(record.get("head"), _CM_PER_IN),
|
||||
}
|
||||
if kind == "Medication":
|
||||
return {"name": _name(record.get("medicationSelection")), "amount": record.get("amount")}
|
||||
if kind == "Vaccine":
|
||||
return {"name": _name(record.get("vaccineType"))}
|
||||
if kind == "Milestone":
|
||||
return {"name": _name(record.get("milestoneType"))}
|
||||
if kind == "OtherActivity":
|
||||
return {"name": _name(record.get("desc")), "duration": int(record.get("duration") or 0)}
|
||||
return {}
|
||||
|
||||
|
||||
def _describe(kind: str, details: Mapping[str, Any]) -> str:
|
||||
if kind == "Diaper":
|
||||
return ", ".join(filter(None, (details.get("status"), details.get("amount"))))
|
||||
if kind == "Sleep":
|
||||
return f"{details['duration']} min" if details.get("duration") else ""
|
||||
if kind in ("Formula", "Pumped", "Pump"):
|
||||
return f"{details['volume_ml']:.0f} ml" if details.get("volume_ml") else ""
|
||||
if kind == "Nursing":
|
||||
sides = (("left", "left_minutes"), ("right", "right_minutes"), ("both", "both_minutes"))
|
||||
return ", ".join(f"{label} {details[key]} min" for label, key in sides if details.get(key))
|
||||
if kind == "Temperature":
|
||||
return f"{details['celsius']:.1f} °C" if details.get("celsius") is not None else ""
|
||||
if kind == "Growth":
|
||||
parts = (("weight_kg", "kg"), ("length_cm", "cm"), ("head_cm", "cm head"))
|
||||
return ", ".join(f"{details[key]:.1f} {unit}" for key, unit in parts if details.get(key))
|
||||
return details.get("name") or ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Baby:
|
||||
id: str
|
||||
name: str
|
||||
record: Mapping[str, Any] = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Entry:
|
||||
"""One logged activity, in metric units. ``time`` is when it started."""
|
||||
|
||||
id: str
|
||||
kind: str
|
||||
baby_id: Optional[str]
|
||||
time: datetime
|
||||
note: str
|
||||
details: Mapping[str, Any]
|
||||
|
||||
@property
|
||||
def end(self) -> datetime:
|
||||
return self.time + timedelta(minutes=self.details.get("duration") or 0)
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
label = _LABELS.get(self.kind, self.kind)
|
||||
description = _describe(self.kind, self.details)
|
||||
return f"{label}: {description}" if description else label
|
||||
|
||||
|
||||
class Replica:
|
||||
"""The account's babies and activities. Not safe to use from several threads."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.babies: dict[str, Baby] = {}
|
||||
self._entries: dict[str, Entry] = {}
|
||||
self._newest: dict[str, Optional[datetime]] = {}
|
||||
self._by_baby: Optional[dict[Optional[str], list[Entry]]] = None
|
||||
|
||||
@classmethod
|
||||
def from_seed(cls, connection: sqlite3.Connection) -> "Replica":
|
||||
replica = cls()
|
||||
for baby in read_babies(connection):
|
||||
replica.apply(OpCode.INSERT, {TYPE_FIELD: "Baby", **baby})
|
||||
for record in read_records(connection):
|
||||
replica.apply(OpCode.INSERT, record)
|
||||
return replica
|
||||
|
||||
def apply(self, op: OpCode, record: Mapping[str, Any]) -> None:
|
||||
"""Apply a record from the seed or a transaction log.
|
||||
|
||||
Logs from different devices arrive device by device rather than in the
|
||||
order things happened, so a change older than the one held is ignored.
|
||||
Records that are neither babies nor activities, such as selection lists
|
||||
and pictures, are ignored too.
|
||||
"""
|
||||
if op == OpCode.CONFLICT:
|
||||
self._apply_conflict(record)
|
||||
return
|
||||
kind = record.get(TYPE_FIELD)
|
||||
is_baby = kind == "Baby"
|
||||
if not is_baby and "time" not in record:
|
||||
return
|
||||
object_id = record["objectID"]
|
||||
timestamp = _date(record.get("timestamp"))
|
||||
newest = self._newest.get(object_id)
|
||||
if timestamp and newest and timestamp < newest:
|
||||
return
|
||||
self._newest[object_id] = timestamp or newest
|
||||
|
||||
if op == OpCode.DELETE:
|
||||
self.babies.pop(object_id, None)
|
||||
self._entries.pop(object_id, None)
|
||||
elif is_baby:
|
||||
self.babies[object_id] = Baby(object_id, record.get("name") or "", dict(record))
|
||||
else:
|
||||
baby = record.get("baby")
|
||||
self._entries[object_id] = Entry(
|
||||
id=object_id,
|
||||
kind=kind,
|
||||
baby_id=baby.get("objectID") if baby else None,
|
||||
time=_date(record["time"]),
|
||||
note=record.get("note") or "",
|
||||
details=_details(kind, record),
|
||||
)
|
||||
self._by_baby = None
|
||||
|
||||
def _apply_conflict(self, record: Mapping[str, Any]) -> None:
|
||||
# Two devices created the same baby separately. The winner's record stays
|
||||
# and the loser's activities move to it. Selection conflicts change only
|
||||
# the names held inside activities, so they are left alone.
|
||||
winner = record.get("winner") or {}
|
||||
loser = record.get("loser") or {}
|
||||
if loser.get(TYPE_FIELD) != "Baby":
|
||||
return
|
||||
self.apply(OpCode.UPDATE, winner)
|
||||
self.babies.pop(loser["objectID"], None)
|
||||
for entry in list(self._entries.values()):
|
||||
if entry.baby_id == loser["objectID"]:
|
||||
self._entries[entry.id] = Entry(
|
||||
entry.id, entry.kind, winner["objectID"], entry.time, entry.note, entry.details
|
||||
)
|
||||
self._by_baby = None
|
||||
|
||||
def entries(self, baby_id: Optional[str], kinds: Optional[Collection[str]] = None) -> Iterable[Entry]:
|
||||
"""Iterate over a baby's entries, newest first. ``None`` is entries with no baby."""
|
||||
if self._by_baby is None:
|
||||
grouped: dict[Optional[str], list[Entry]] = {}
|
||||
for entry in self._entries.values():
|
||||
grouped.setdefault(entry.baby_id, []).append(entry)
|
||||
for entries in grouped.values():
|
||||
entries.sort(key=lambda entry: entry.time, reverse=True)
|
||||
self._by_baby = grouped
|
||||
entries = self._by_baby.get(baby_id, [])
|
||||
return entries if kinds is None else (entry for entry in entries if entry.kind in kinds)
|
||||
|
||||
def latest(self, baby_id: Optional[str], kinds: Optional[Collection[str]] = None) -> Optional[Entry]:
|
||||
return next(iter(self.entries(baby_id, kinds)), None)
|
||||
|
||||
def since(self, baby_id: Optional[str], start: datetime, kinds: Optional[Collection[str]] = None) -> list[Entry]:
|
||||
result = []
|
||||
for entry in self.entries(baby_id, kinds):
|
||||
if entry.time < start:
|
||||
break
|
||||
result.append(entry)
|
||||
return result
|
||||
Reference in new issue
Block a user