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:
arneandClaude Opus 5.5 committed 2026-11-04 10:04:39 +00:00
commit 6466f002e0
51 files changed
+5199

No files matched your search

@@ -0,0 +1,344 @@
"""Wire format for Baby Tracker sync records.
A record is a JSON object that travels base64 encoded inside a transaction.
Field names mirror the app's Java classes because the app serializes them with
Gson reflection. Records stay plain dicts so that fields this module does not
know about survive a download/modify/upload round trip.
"""
from __future__ import annotations
import base64
import json
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import IntEnum
from typing import Any, Mapping, Optional
TYPE_FIELD = "BCObjectType"
# Types registered with the app's RuntimeTypeAdapterFactory (utility/GsonUtils).
# The Android app rejects a record whose BCObjectType is not in this set.
KNOWN_TYPES = frozenset(
{
"Activity", "Baby", "BabyActivity", "Bath", "Bottle", "ConflictRecord",
"Diaper", "EditableSelection", "Feed", "Formula", "Growth", "Journal",
"Joy", "Medication", "MedicationSelection", "Milestone",
"MilestoneSelection", "Nursing", "NursingSession", "OtherActivity",
"OtherActivityDescription", "OtherFeed", "OtherFeedSelection", "Picture",
"Pump", "Pumped", "Sleep", "Temperature", "Vaccine", "VaccineSelection",
}
)
class OpCode(IntEnum):
INSERT = 0
UPDATE = 1
DELETE = 2
RELIVE = 3
CONFLICT = 4
class DiaperStatus(IntEnum):
WET = 0
POOPY = 1
MIXED = 2
DRY = 3
class DiaperAmount(IntEnum):
NONE = 0
LIGHT = 1
NORMAL = 2
HEAVY = 3
OVERFLOW = 4
NOSET = 5
class NursingFinishSide(IntEnum):
NOT_SET = 0
LEFT = 1
RIGHT = 2
class PumpFinishSide(IntEnum):
NONE = 0
LEFT = 1
RIGHT = 2
class PumpSides(IntEnum):
BOTH = 0
LEFT = 1
RIGHT = 2
class MediaType(IntEnum):
PICTURE = 0
VIDEO = 1
AUDIO = 2
# ---------------------------------------------------------------------------
# Dates
# ---------------------------------------------------------------------------
# Older app versions wrote localized AM/PM markers, and DateTypeAdapter falls
# back to rewriting them. Order matches the app.
_MERIDIEM_MARKERS = (
("a.m.", "AM"), ("p.m.", "PM"), ("vorm.", "AM"), ("nachm.", "PM"),
("午前", "AM"), ("午後", "PM"), ("上午", "AM"), ("下午", "PM"),
("a. m.", "AM"), ("p. m.", "PM"), ("fm", "AM"), ("em", "PM"),
("오전", "AM"), ("오후", "PM"),
)
def format_date(value: datetime) -> str:
"""Format a datetime as the app does: ``yyyy-MM-dd HH:mm:ss Z`` in GMT."""
if value.tzinfo is None:
raise ValueError("datetime must be timezone aware")
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S +0000")
def parse_date(value: str) -> datetime:
"""Parse any date form accepted by the app's DateTypeAdapter."""
try:
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S %z")
except ValueError:
pass
normalized = value
for marker, meridiem in _MERIDIEM_MARKERS:
normalized = normalized.replace(marker, meridiem)
return datetime.strptime(normalized, "%Y-%m-%d %I:%M:%S %p %z")
def _now() -> datetime:
return datetime.now(timezone.utc)
def _raw_utc_offset(moment: datetime) -> int:
"""The app's ``timezone`` field: UTC offset in seconds, excluding DST."""
local = moment.astimezone()
offset = local.utcoffset() or timedelta(0)
dst = local.dst() or timedelta(0)
return int((offset - dst).total_seconds())
# ---------------------------------------------------------------------------
# Record encoding
# ---------------------------------------------------------------------------
def _json_default(value: Any) -> Any:
if isinstance(value, datetime):
return format_date(value)
raise TypeError(f"cannot serialize {type(value).__name__}")
def _gson_enums(value: Any) -> Any:
# Gson writes @SerializedName enums as strings, so DiaperStatus.POOPY is "1".
# IntEnum is a JSON number to the json module, so this has to run first.
if isinstance(value, IntEnum):
return str(int(value))
if isinstance(value, Mapping):
return {key: _gson_enums(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_gson_enums(item) for item in value]
return value
def encode_object(obj: Mapping[str, Any]) -> str:
"""Encode a record for the ``Transaction`` field of a transaction."""
if TYPE_FIELD not in obj:
raise ValueError(f"record has no {TYPE_FIELD}")
text = json.dumps(_gson_enums(obj), indent=2, ensure_ascii=False, default=_json_default)
return base64.b64encode(text.encode("utf-8")).decode("ascii")
def decode_object(encoded: str) -> dict[str, Any]:
"""Decode the ``Transaction`` field of a transaction into a record."""
return json.loads(base64.b64decode(encoded).decode("utf-8"))
@dataclass(frozen=True)
class Device:
"""A device on the account and the newest SyncID the server holds for it."""
uuid: str
last_sync_id: int
@classmethod
def from_json(cls, data: Mapping[str, Any]) -> "Device":
return cls(uuid=data["DeviceUUID"], last_sync_id=int(data["LastSyncID"]))
@dataclass(frozen=True)
class Transaction:
"""One entry of a device's transaction log."""
sync_id: int
op: OpCode
payload: str
@classmethod
def from_json(cls, data: Mapping[str, Any]) -> "Transaction":
# OPCode is a string when written by Gson. Gson also reads numbers here,
# so int() accepts both forms.
return cls(
sync_id=int(data["SyncID"]),
op=OpCode(int(data["OPCode"])),
payload=data["Transaction"],
)
@classmethod
def create(cls, sync_id: int, op: OpCode, obj: Mapping[str, Any]) -> "Transaction":
return cls(sync_id=sync_id, op=op, payload=encode_object(obj))
def to_json(self) -> dict[str, Any]:
return {"SyncID": self.sync_id, "OPCode": str(int(self.op)), "Transaction": self.payload}
@property
def object(self) -> dict[str, Any]:
return decode_object(self.payload)
@property
def object_type(self) -> Optional[str]:
return self.object.get(TYPE_FIELD)
# ---------------------------------------------------------------------------
# Measures. Units follow get*OnInternational() in the app's *Measure classes.
# ---------------------------------------------------------------------------
def measure(value: float, english: bool = False) -> dict[str, Any]:
"""Build a measure, in the unit given by ``english``.
======== ======== =======
kind metric english
======== ======== =======
volume ml fl oz
weight kg lb
length cm in
temp °C °F
======== ======== =======
"""
return {"englishMeasure": english, "value": float(value)}
# ---------------------------------------------------------------------------
# Record builders
# ---------------------------------------------------------------------------
def new_activity(
type_name: str,
baby: Optional[Mapping[str, Any]],
time: Optional[datetime] = None,
note: str = "",
**fields: Any,
) -> dict[str, Any]:
"""Build a new activity record, with the defaults of the app's constructors.
``baby`` is the full baby record, nested without its own BCObjectType. Pass
``None`` only for ``Pump``, the one activity type that has no baby.
Extra keyword arguments are written as record fields under the given name.
"""
now = _now()
moment = time or now
record: dict[str, Any] = {
TYPE_FIELD: type_name,
"note": note,
"pictureLoaded": True,
"pictureNote": [],
"time": moment,
"timezone": _raw_utc_offset(moment),
"newFlage": True,
"objectID": str(uuid.uuid4()),
"timestamp": now,
}
if baby is not None:
record["baby"] = {key: value for key, value in baby.items() if key != TYPE_FIELD}
record.update(fields)
return record
def diaper(
baby: Mapping[str, Any],
status: DiaperStatus,
amount: DiaperAmount = DiaperAmount.NOSET,
**kwargs: Any,
) -> dict[str, Any]:
"""Build a diaper record.
``amount`` defaults to NOSET because the app's statistics count WET with
amount NONE as a dry diaper.
"""
return new_activity("Diaper", baby, status=status, amount=amount, **kwargs)
def sleep(baby: Mapping[str, Any], minutes: int, **kwargs: Any) -> dict[str, Any]:
return new_activity("Sleep", baby, duration=minutes, **kwargs)
def formula(baby: Mapping[str, Any], amount: float, english: bool = False, **kwargs: Any) -> dict[str, Any]:
return new_activity("Formula", baby, amount=measure(amount, english), **kwargs)
def pumped(baby: Mapping[str, Any], amount: float, english: bool = False, **kwargs: Any) -> dict[str, Any]:
"""A feed of expressed milk. ``Pump`` is the separate pumping session record."""
return new_activity("Pumped", baby, amount=measure(amount, english), **kwargs)
def nursing(
baby: Mapping[str, Any],
left_minutes: int = 0,
right_minutes: int = 0,
both_minutes: int = 0,
finish_side: NursingFinishSide = NursingFinishSide.NOT_SET,
**kwargs: Any,
) -> dict[str, Any]:
return new_activity(
"Nursing",
baby,
leftDuration=left_minutes,
rightDuration=right_minutes,
bothDuration=both_minutes,
finishSide=finish_side,
**kwargs,
)
def temperature(baby: Mapping[str, Any], value: float, english: bool = False, **kwargs: Any) -> dict[str, Any]:
return new_activity("Temperature", baby, temperature=measure(value, english), **kwargs)
def growth(
baby: Mapping[str, Any],
weight: Optional[float] = None,
length: Optional[float] = None,
head: Optional[float] = None,
english: bool = False,
**kwargs: Any,
) -> dict[str, Any]:
fields: dict[str, Any] = {"deleted": False, "queryIndex": 0}
if weight is not None:
fields["weight"] = measure(weight, english)
if length is not None:
fields["length"] = measure(length, english)
if head is not None:
fields["head"] = measure(head, english)
return new_activity("Growth", baby, **fields, **kwargs)
def bath(baby: Mapping[str, Any], **kwargs: Any) -> dict[str, Any]:
return new_activity("Bath", baby, **kwargs)
def joy(baby: Mapping[str, Any], **kwargs: Any) -> dict[str, Any]:
return new_activity("Joy", baby, **kwargs)
def journal(baby: Mapping[str, Any], note: str, **kwargs: Any) -> dict[str, Any]:
return new_activity("Journal", baby, note=note, **kwargs)