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,87 @@
|
||||
"""Unofficial Python client for the Baby Tracker sync server."""
|
||||
|
||||
from .client import (
|
||||
AccountChangedError,
|
||||
AccountStateError,
|
||||
AuthenticationError,
|
||||
BabyTrackerClient,
|
||||
BabyTrackerError,
|
||||
ConflictError,
|
||||
MissingSeedError,
|
||||
NotFoundError,
|
||||
)
|
||||
from .models import (
|
||||
Device,
|
||||
DiaperAmount,
|
||||
DiaperStatus,
|
||||
MediaType,
|
||||
NursingFinishSide,
|
||||
OpCode,
|
||||
PumpFinishSide,
|
||||
PumpSides,
|
||||
Transaction,
|
||||
bath,
|
||||
decode_object,
|
||||
diaper,
|
||||
encode_object,
|
||||
format_date,
|
||||
formula,
|
||||
growth,
|
||||
journal,
|
||||
joy,
|
||||
measure,
|
||||
new_activity,
|
||||
nursing,
|
||||
parse_date,
|
||||
pumped,
|
||||
sleep,
|
||||
temperature,
|
||||
)
|
||||
from .replica import BOTTLE_KINDS, FEED_KINDS, Baby, Entry, Replica
|
||||
from .seed import extract_seed_db, open_seed_db, read_babies, read_cursors, read_records
|
||||
|
||||
__all__ = [
|
||||
"AccountChangedError",
|
||||
"AccountStateError",
|
||||
"AuthenticationError",
|
||||
"BOTTLE_KINDS",
|
||||
"Baby",
|
||||
"BabyTrackerClient",
|
||||
"BabyTrackerError",
|
||||
"ConflictError",
|
||||
"Device",
|
||||
"DiaperAmount",
|
||||
"DiaperStatus",
|
||||
"Entry",
|
||||
"FEED_KINDS",
|
||||
"MediaType",
|
||||
"MissingSeedError",
|
||||
"NotFoundError",
|
||||
"NursingFinishSide",
|
||||
"OpCode",
|
||||
"PumpFinishSide",
|
||||
"PumpSides",
|
||||
"Replica",
|
||||
"Transaction",
|
||||
"bath",
|
||||
"decode_object",
|
||||
"diaper",
|
||||
"encode_object",
|
||||
"extract_seed_db",
|
||||
"format_date",
|
||||
"formula",
|
||||
"growth",
|
||||
"journal",
|
||||
"joy",
|
||||
"measure",
|
||||
"new_activity",
|
||||
"nursing",
|
||||
"open_seed_db",
|
||||
"parse_date",
|
||||
"pumped",
|
||||
"read_babies",
|
||||
"read_cursors",
|
||||
"read_records",
|
||||
"sleep",
|
||||
"temperature",
|
||||
]
|
||||
@@ -0,0 +1,371 @@
|
||||
"""HTTP client for the Baby Tracker sync server.
|
||||
|
||||
Reconstructed from com.nighp.babytracker_android.sync.SyncImple (app 4.53).
|
||||
See docs/API.md for the protocol and for what is inferred rather than observed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Iterator, Mapping, MutableMapping, Optional, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
from .models import Device, OpCode, Transaction
|
||||
|
||||
DEFAULT_BASE_URL = "https://prodapp.babytrackers.com"
|
||||
PAGE_SIZE = 500
|
||||
|
||||
PHOTO_BOUNDARY = "__BABY_TRACKER_BOUNDARY____Photo__"
|
||||
SEED_BOUNDARY = "__BABY_TRACKER_BOUNDARY____SEED__"
|
||||
|
||||
# The app sends zeros: App_Info's constructor sets both and nothing changes them.
|
||||
_APP_INFO = {"AccountType": 0, "AppType": 0}
|
||||
_TOKEN_APP_TYPE = 5
|
||||
|
||||
PathLike = Union[str, Path]
|
||||
|
||||
|
||||
class BabyTrackerError(Exception):
|
||||
"""The server returned an unexpected status."""
|
||||
|
||||
def __init__(self, status: int, message: str = ""):
|
||||
super().__init__(f"HTTP {status}: {message}" if message else f"HTTP {status}")
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
class AuthenticationError(BabyTrackerError):
|
||||
"""Bad credentials, or a session that could not be renewed (400 or 401)."""
|
||||
|
||||
|
||||
class AccountStateError(BabyTrackerError):
|
||||
"""Login returned 307. The app turns sync off; the cause is not documented."""
|
||||
|
||||
|
||||
class ConflictError(BabyTrackerError):
|
||||
"""409, for example when creating a group that already exists."""
|
||||
|
||||
|
||||
class NotFoundError(BabyTrackerError):
|
||||
"""404: no such seed or picture."""
|
||||
|
||||
|
||||
class MissingSeedError(BabyTrackerError):
|
||||
"""424: a group was finalized before its seed was uploaded."""
|
||||
|
||||
|
||||
class AccountChangedError(Exception):
|
||||
"""A renewed session belongs to another account than the first login.
|
||||
|
||||
The app turns sync off in this case, as merging would mix two accounts.
|
||||
"""
|
||||
|
||||
|
||||
_ERRORS_BY_STATUS = {
|
||||
400: AuthenticationError,
|
||||
401: AuthenticationError,
|
||||
307: AccountStateError,
|
||||
404: NotFoundError,
|
||||
409: ConflictError,
|
||||
424: MissingSeedError,
|
||||
}
|
||||
|
||||
|
||||
class BabyTrackerClient:
|
||||
"""A sync client, which the server sees as one more device on the account.
|
||||
|
||||
Reuse the same ``device_uuid`` between runs: it identifies this client's
|
||||
transaction log. A new value is a new device on the account.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
device_uuid: Optional[str] = None,
|
||||
device_name: str = "Python client",
|
||||
device_os_info: str = "Python",
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
timeout: float = 60,
|
||||
):
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.device_uuid = device_uuid or str(uuid.uuid4())
|
||||
self.device_name = device_name
|
||||
self.device_os_info = device_os_info
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.account_id: Optional[int] = None
|
||||
self._session = requests.Session()
|
||||
self._next_sync_id: Optional[int] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Transport
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _send(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
# HttpURLConnection follows redirects for GET only. A 307 on POST reaches
|
||||
# the app's error handling, so it must not be followed here either.
|
||||
return self._session.request(
|
||||
method,
|
||||
self.base_url + path,
|
||||
timeout=self.timeout,
|
||||
allow_redirects=method == "GET",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _send_json(self, method: str, path: str, body: Mapping[str, Any]) -> requests.Response:
|
||||
return self._send(
|
||||
method,
|
||||
path,
|
||||
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", "charset": "utf-8"},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _check(response: requests.Response, *expected: int) -> requests.Response:
|
||||
if response.status_code in expected:
|
||||
return response
|
||||
error = _ERRORS_BY_STATUS.get(response.status_code, BabyTrackerError)
|
||||
raise error(response.status_code, response.text.strip())
|
||||
|
||||
def _authed(self, send: Any, *expected: int) -> requests.Response:
|
||||
"""Run a request, logging in again once if the session has expired."""
|
||||
response = send()
|
||||
if response.status_code == 401:
|
||||
self.login()
|
||||
response = send()
|
||||
return self._check(response, *expected)
|
||||
|
||||
def _user_info(self) -> dict[str, Any]:
|
||||
return {
|
||||
"AppInfo": _APP_INFO,
|
||||
"Device": {
|
||||
"DeviceName": self.device_name,
|
||||
"DeviceOSInfo": self.device_os_info,
|
||||
"DeviceUUID": self.device_uuid,
|
||||
},
|
||||
"EmailAddress": self.email,
|
||||
"Password": self.password,
|
||||
}
|
||||
|
||||
def _remember_account(self, response: requests.Response) -> int:
|
||||
account_id = response.json().get("AccountID")
|
||||
if not account_id:
|
||||
raise BabyTrackerError(response.status_code, "response has no AccountID")
|
||||
if self.account_id is not None and account_id != self.account_id:
|
||||
raise AccountChangedError(f"logged in to account {account_id}, expected {self.account_id}")
|
||||
self.account_id = account_id
|
||||
return account_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def login(self) -> int:
|
||||
"""Start a session and return the account ID.
|
||||
|
||||
The request carries this device's details, so expect it to be added to
|
||||
the account's devices.
|
||||
"""
|
||||
response = self._check(self._send_json("POST", "/session", self._user_info()), 200)
|
||||
return self._remember_account(response)
|
||||
|
||||
def logout(self) -> None:
|
||||
self._authed(lambda: self._send("DELETE", "/session"), 200)
|
||||
|
||||
def forgot_password(self, language: str = "en") -> None:
|
||||
"""Ask the server to send a password reset email."""
|
||||
body = {"EmailAddress": self.email, "Language": language, "AppInfo": _APP_INFO}
|
||||
self._check(self._send_json("POST", "/account/forgot_password", body), 200)
|
||||
|
||||
def register_push_token(self, token: str) -> None:
|
||||
"""Register a Firebase token for "new data" push notifications."""
|
||||
body = {"TokenNative": token, "TokenAppType": _TOKEN_APP_TYPE}
|
||||
self._authed(lambda: self._send_json("POST", "/device_token", body), 200, 201)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reading the account
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_devices(self) -> list[Device]:
|
||||
response = self._authed(lambda: self._send("GET", "/account/device"), 200)
|
||||
return [Device.from_json(item) for item in response.json()]
|
||||
|
||||
def get_transactions(self, device_uuid: str, after_sync_id: int = 0) -> list[Transaction]:
|
||||
"""Fetch one page of a device's log, starting after ``after_sync_id``."""
|
||||
path = f"/account/transaction/{quote(device_uuid, safe='')}/{int(after_sync_id)}"
|
||||
response = self._authed(lambda: self._send("GET", path), 200)
|
||||
return [Transaction.from_json(item) for item in response.json() or []]
|
||||
|
||||
def iter_transactions(self, device_uuid: str, after_sync_id: int = 0) -> Iterator[Transaction]:
|
||||
"""Iterate over a device's log, fetching pages until a short one."""
|
||||
cursor = after_sync_id
|
||||
while True:
|
||||
page = self.get_transactions(device_uuid, cursor)
|
||||
for item in page:
|
||||
if item.sync_id > cursor:
|
||||
yield item
|
||||
newest = max((item.sync_id for item in page), default=cursor)
|
||||
# Also stop if a full page did not advance, rather than loop forever.
|
||||
if len(page) < PAGE_SIZE or newest <= cursor:
|
||||
return
|
||||
cursor = newest
|
||||
|
||||
def pull(
|
||||
self, cursors: MutableMapping[str, int], include_own: bool = False
|
||||
) -> Iterator[tuple[str, Transaction]]:
|
||||
"""Yield ``(device_uuid, transaction)`` for everything not yet seen.
|
||||
|
||||
``cursors`` maps device UUIDs to the last SyncID handled and is updated
|
||||
after each item is consumed, so it can be saved and passed to a later
|
||||
call. This device's own log is skipped, as in the app, unless
|
||||
``include_own`` is set. A client rebuilding its state from the seed needs
|
||||
it, because the seed does not contain what this device uploaded later.
|
||||
"""
|
||||
for device in self.get_devices():
|
||||
if device.uuid == self.device_uuid and not include_own:
|
||||
continue
|
||||
cursor = cursors.get(device.uuid, 0)
|
||||
if cursor >= device.last_sync_id:
|
||||
continue
|
||||
for item in self.iter_transactions(device.uuid, cursor):
|
||||
yield device.uuid, item
|
||||
cursors[device.uuid] = item.sync_id
|
||||
|
||||
def download_seed(self, dest_path: PathLike) -> Path:
|
||||
"""Download the account's seed zip. See :mod:`babytracker_api.seed`."""
|
||||
return self._download("/account/seed", dest_path)
|
||||
|
||||
def download_picture(self, file_name: str, dest_path: PathLike) -> Path:
|
||||
"""Download a picture.
|
||||
|
||||
``file_name`` is a server file name. For a ``Picture`` record with
|
||||
``fileName`` X these are ``X.jpg`` (``X.mp4`` for a video) and
|
||||
``X__thumbnail.jpg``.
|
||||
"""
|
||||
return self._download(f"/account/picture/{quote(file_name, safe='')}", dest_path)
|
||||
|
||||
def _download(self, path: str, dest_path: PathLike) -> Path:
|
||||
dest = Path(dest_path)
|
||||
response = self._authed(lambda: self._send("GET", path, stream=True), 200)
|
||||
with dest.open("wb") as target:
|
||||
for chunk in response.iter_content(chunk_size=65536):
|
||||
target.write(chunk)
|
||||
return dest
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writing to the account
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def upload_transaction(self, transaction: Transaction) -> None:
|
||||
self._authed(lambda: self._send_json("POST", "/account/transaction", transaction.to_json()), 201)
|
||||
|
||||
def next_sync_id(self) -> int:
|
||||
"""Return the next SyncID for this device's log.
|
||||
|
||||
The app counts these locally. This client resumes from what the server
|
||||
reports, then counts locally, so only one process per ``device_uuid``
|
||||
may upload at a time.
|
||||
"""
|
||||
if self._next_sync_id is None:
|
||||
last = next((d.last_sync_id for d in self.get_devices() if d.uuid == self.device_uuid), 0)
|
||||
self._next_sync_id = last + 1
|
||||
return self._next_sync_id
|
||||
|
||||
def _push(self, op: OpCode, obj: Mapping[str, Any]) -> Transaction:
|
||||
transaction = Transaction.create(self.next_sync_id(), op, obj)
|
||||
self.upload_transaction(transaction)
|
||||
self._next_sync_id = transaction.sync_id + 1
|
||||
return transaction
|
||||
|
||||
def add(self, obj: Mapping[str, Any]) -> Transaction:
|
||||
"""Upload a new record, as built by the helpers in ``models``."""
|
||||
return self._push(OpCode.INSERT, obj)
|
||||
|
||||
def update(self, obj: Mapping[str, Any]) -> Transaction:
|
||||
"""Upload a changed record. Other devices replace their copy with it."""
|
||||
return self._push(OpCode.UPDATE, {**obj, "timestamp": datetime.now(timezone.utc)})
|
||||
|
||||
def delete(self, obj: Mapping[str, Any]) -> Transaction:
|
||||
"""Upload a deletion. The whole record is sent, not just its ID."""
|
||||
return self._push(OpCode.DELETE, {**obj, "timestamp": datetime.now(timezone.utc)})
|
||||
|
||||
def upload_picture(self, path: PathLike, staging: bool = False) -> None:
|
||||
"""Upload a picture. ``staging`` is for pictures of an unfinished group."""
|
||||
url = "/staging_account/picture" if staging else "/account/picture"
|
||||
self._upload_file(url, Path(path), "picture", "image/jpeg", PHOTO_BOUNDARY)
|
||||
|
||||
def upload_clone_data(self, path: PathLike, file_name: Optional[str] = None) -> None:
|
||||
"""Upload a backup zip. The app sends one when a device joins a group.
|
||||
|
||||
The app names the file after the device ID and carries on joining if
|
||||
this upload fails. The app has no call that downloads this data again.
|
||||
"""
|
||||
source = Path(path)
|
||||
self._upload_file(
|
||||
"/account/clonedata",
|
||||
source,
|
||||
"clonedata",
|
||||
"application/zip",
|
||||
PHOTO_BOUNDARY,
|
||||
file_name=file_name or source.name,
|
||||
)
|
||||
|
||||
def _upload_file(
|
||||
self,
|
||||
path: str,
|
||||
source: Path,
|
||||
field: str,
|
||||
content_type: str,
|
||||
boundary: str,
|
||||
file_name: Optional[str] = None,
|
||||
) -> None:
|
||||
head = (
|
||||
f"\r\n--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="{field}"; filename="{file_name or source.name}"\r\n'
|
||||
f"Content-Type: {content_type}\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
tail = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
body = head + source.read_bytes() + tail
|
||||
headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"}
|
||||
self._authed(lambda: self._send("POST", path, data=body, headers=headers), 201)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Group management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_group(self, seed_zip: PathLike, pictures: Iterable[PathLike] = ()) -> int:
|
||||
"""Create an account from a seed zip holding the app's database.
|
||||
|
||||
Returns the new account ID. Raises :class:`ConflictError` if the email
|
||||
is already in use.
|
||||
"""
|
||||
self._check(self._send_json("POST", "/staging_account", self._user_info()), 200)
|
||||
return self._finish_group(seed_zip, pictures)
|
||||
|
||||
def reset_group(self, seed_zip: PathLike, pictures: Iterable[PathLike] = (), *, confirm: bool = False) -> int:
|
||||
"""Replace the whole account with a seed zip.
|
||||
|
||||
Judging by the endpoint and the app's flow, this discards the account's
|
||||
server-side data and pushes its other devices out of sync, so
|
||||
``confirm=True`` is required. It has not been tried against the server.
|
||||
"""
|
||||
if not confirm:
|
||||
raise ValueError("reset_group replaces all account data; pass confirm=True")
|
||||
self._check(self._send_json("POST", "/account_reset", self._user_info()), 200)
|
||||
return self._finish_group(seed_zip, pictures)
|
||||
|
||||
def _finish_group(self, seed_zip: PathLike, pictures: Iterable[PathLike]) -> int:
|
||||
self._upload_file("/staging_account/seed", Path(seed_zip), "seed", "application/zip", SEED_BOUNDARY, "seed.zip")
|
||||
for picture in pictures:
|
||||
self.upload_picture(picture, staging=True)
|
||||
response = self._check(self._send("PUT", "/staging_account"), 200)
|
||||
self.account_id = None
|
||||
self._next_sync_id = None
|
||||
return self._remember_account(response)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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