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,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)
|
||||
Reference in new issue
Block a user