commit 6466f002e00a7efa04d246695ded30ba5f447227 Author: Arne Schauf Date: Wed Nov 4 10:04:39 2026 +0000 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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..1c562b4 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,27 @@ +name: Validate + +on: + push: + pull_request: + +jobs: + hassfest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: home-assistant/actions/hassfest@master + hacs: + runs-on: ubuntu-latest + steps: + - uses: hacs/action@main + with: + category: integration + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install requests pyyaml + - run: python -m unittest discover -s tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6db88e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.apk +__pycache__/ +*.pyc +*.egg-info/ +build/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6606743 --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# Baby Tracker for Home Assistant + +An unofficial [HACS](https://hacs.xyz) integration that connects Home Assistant to the +sync group of the Baby Tracker app (`com.nighp.babytracker_android`). You can see your +baby's log in Home Assistant and add to it, and entries sync with the app on your other +devices. + +It also contains a [Python client](#python-client) for the sync API, which was reverse +engineered from version 4.53 of the Android app. + +> **Unofficial and partly unverified.** Nothing here comes from the app's developer, and +> the server could change without notice. Reading has been checked against the real +> server, but **uploading has not**. See [Your first test](#your-first-test), and back up +> your data first. + +## Requirements + +- Home Assistant 2025.1 or newer. +- A sync group, set up in the app's sync settings. Its email and password are what you + sign in with. + +## Install + +### With HACS + +The repository has to be public on GitHub. Before publishing it, replace +`YOUR_GITHUB_USERNAME` in `custom_components/babytracker/manifest.json`. + +1. In HACS, open the menu and choose **Custom repositories**. +2. Add the repository URL, with the type **Integration**. +3. Find **Baby Tracker** in HACS, download it, and restart Home Assistant. + +### By hand + +Copy `custom_components/babytracker` into the `custom_components` folder of your Home +Assistant configuration, then restart. + +### Set it up + +Go to **Settings → Devices & services → Add integration → Baby Tracker** and sign in. + +Home Assistant joins the group as one more device, named "Home Assistant". Remove the +integration and that device stays listed on the account, because the app has no call +that removes a device. + +## Your first test + +The first upload is the one thing that has never been tried against the real server, so +start with something easy to remove: + +1. Go to **Developer tools → Actions** and run **Baby Tracker: Log journal entry** with + the text `Home Assistant test`. +2. Check that it appears in the app on your phone, after the app has synced. +3. Restart Home Assistant and check that it still appears, once, on `sensor._recent_activity`. +4. Delete it in the app. Within about five minutes it should disappear from Home Assistant. + +If any step fails, stop uploading and open an issue with what you saw. + +## What you get + +Each baby becomes a device with these entities. + +| Entity | Shows | +|---|---| +| `calendar._log` | The whole log, for the calendar view | +| `sensor._recent_activity` | Time of the latest entry, with the last 20 in its `entries` attribute | +| `sensor._last_diaper` | Time of the last diaper, with status and amount as attributes | +| `sensor._last_feeding` | Start of the last feed of any type, with its details | +| `sensor._last_sleep` | Start of the last sleep, with `end` and `duration` attributes | +| `sensor._diapers_today` | Count | +| `sensor._feedings_today` | Count | +| `sensor._sleep_today` | Minutes | +| `sensor._bottle_volume_today` | Formula and pumped milk | +| `sensor._temperature` | Latest reading | +| `sensor._weight` | Latest measurement | +| `sensor._length` | Latest measurement | +| `sensor._head_circumference` | Latest measurement | + +Entries count towards the day they started on, so a sleep that runs past midnight counts +for the day it began. Entries made on other devices appear within about five minutes. + +## Adding entries + +Each of these actions takes optional `baby`, `time` and `note` fields. + +| Action | Fields | +|---|---| +| `babytracker.log_diaper` | `status` (wet, poopy, mixed, dry), `amount` (light, normal, heavy, overflow) | +| `babytracker.log_sleep` | `duration` in minutes | +| `babytracker.log_bottle` | `amount`, `kind` (formula, pumped), `unit` (ml, oz) | +| `babytracker.log_nursing` | `left_minutes`, `right_minutes`, `both_minutes`, `finish_side` (left, right) | +| `babytracker.log_temperature` | `value`, `unit` (C, F) | +| `babytracker.log_growth` | `weight`, `length`, `head`, `units` (metric, english) | +| `babytracker.log_bath` | none | +| `babytracker.log_joy` | none | +| `babytracker.log_journal` | `note` is the entry's text, and is required | + +- **`baby`**: the baby's device or their name. Leave it out if the account has one baby. +- **`time`**: when the activity started. Leave it out and the activity is taken to have + just finished, so a 30-minute sleep is logged as starting 30 minutes ago. +- **Units**: these default to your Home Assistant unit system. +- **Failures**: if the upload fails, for example with no internet, the action reports an + error and nothing is saved or retried. +- **Response**: each action can return the new entry's `id`. + +## Dashboard examples + +Buttons to log a diaper in one tap: + +```yaml +type: horizontal-stack +cards: + - type: button + name: Wet + icon: mdi:water + tap_action: + action: perform-action + perform_action: babytracker.log_diaper + data: + status: wet + - type: button + name: Poopy + icon: mdi:emoticon-poop + tap_action: + action: perform-action + perform_action: babytracker.log_diaper + data: + status: poopy +``` + +The log as a list, using a Markdown card. Replace `sam` with your baby's name: + +```yaml +type: markdown +title: Recent activity +content: > + {% for entry in state_attr('sensor.sam_recent_activity', 'entries') or [] %} + - **{{ as_timestamp(entry.time) | timestamp_custom('%H:%M') }}** {{ entry.summary }}{{ ' — ' ~ entry.note if entry.note }} + {% endfor %} +``` + +A reminder when the last feed was over three hours ago: + +```yaml +triggers: + - trigger: template + value_template: > + {{ now() - states('sensor.sam_last_feeding') | as_datetime > timedelta(hours=3) }} +actions: + - action: notify.notify + data: + message: "Last feed was over three hours ago" +``` + +## Limitations + +- **Shown but not addable**: medication, vaccines, milestones, solid foods and other + activities. These pick from lists kept in the app. They do appear on the calendar and + in recent activity. +- **Not shown**: pumping sessions, because they don't belong to a baby. Photos too. +- **No editing or deleting** from Home Assistant. Use the app. +- **Startup**: the integration keeps the log in memory and downloads it again each time + it starts, so large accounts take longer to load. +- **Group resets**: if you reset the group in the app, reload the integration. +- **Password**: Home Assistant stores it unencrypted in its `.storage` folder, as it does + for most integrations. The server has no separate tokens. + +## Python client + +The library works without Home Assistant, and lives inside the integration folder because +HACS installs only that folder. + +```bash +pip install -e . +``` + +The server has no way to query records. It stores a **database snapshot** (the seed) and +an **append-only log per device**. `Replica` combines them into a local copy. + +```python +from babytracker_api import ( + BabyTrackerClient, DiaperStatus, Replica, diaper, extract_seed_db, + formula, open_seed_db, read_cursors, sleep, +) + +# Keep device_uuid stable between runs. A new value is a new device on the account. +client = BabyTrackerClient("you@example.com", "password", device_uuid="") +client.login() + +# Build a local copy from the seed. +extract_seed_db(client.download_seed("seed.zip"), "seed.db") +db = open_seed_db("seed.db") +replica = Replica.from_seed(db) +cursors = read_cursors(db) + +# Add what happened since. Save `cursors` to continue from there next time. +# include_own is needed here because the seed predates this device's own uploads. +for device, item in client.pull(cursors, include_own=True): + replica.apply(item.op, item.object) + +baby = next(iter(replica.babies.values())) +for entry in replica.entries(baby.id): + print(entry.time, entry.summary) + +# Add records. These need the baby's full record. +client.add(diaper(baby.record, DiaperStatus.POOPY)) +client.add(formula(baby.record, 120)) # ml; english=True for fl oz +client.add(sleep(baby.record, minutes=45, note="nap")) + +# Change or remove a record. Records are plain dicts. +record = sleep(baby.record, minutes=30) +client.add(record) +client.update({**record, "duration": 40}) +client.delete(record) +``` + +To check it against your account without changing anything: + +```bash +BABYTRACKER_EMAIL=you@example.com BABYTRACKER_PASSWORD=... python3 examples/smoke_test.py +``` + +See [docs/API.md](docs/API.md) for endpoints, record formats and open questions. + +## Development + +```bash +python3 -m unittest discover -s tests +``` + +The tests use a mock server written from the same analysis of the app. The integration's +tests run against small stand-ins for Home Assistant's APIs in `tests/ha_stubs`, so they +don't show that it works in real Home Assistant. The GitHub workflow adds Home Assistant's +`hassfest` and the HACS validation. diff --git a/custom_components/babytracker/__init__.py b/custom_components/babytracker/__init__.py new file mode 100644 index 0000000..464c346 --- /dev/null +++ b/custom_components/babytracker/__init__.py @@ -0,0 +1,44 @@ +"""The Baby Tracker integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .babytracker_api import BabyTrackerClient +from .const import CONF_DEVICE_UUID, DEVICE_NAME, DOMAIN +from .coordinator import BabyTrackerCoordinator +from .services import async_setup_services + +PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + async_setup_services(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + client = BabyTrackerClient( + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + device_uuid=entry.data[CONF_DEVICE_UUID], + device_name=DEVICE_NAME, + device_os_info=DEVICE_NAME, + ) + # A later login to a different account is then refused rather than mixed in. + client.account_id = int(entry.unique_id) + coordinator = BabyTrackerCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/babytracker/babytracker_api/__init__.py b/custom_components/babytracker/babytracker_api/__init__.py new file mode 100644 index 0000000..f458501 --- /dev/null +++ b/custom_components/babytracker/babytracker_api/__init__.py @@ -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", +] diff --git a/custom_components/babytracker/babytracker_api/client.py b/custom_components/babytracker/babytracker_api/client.py new file mode 100644 index 0000000..6ad57bd --- /dev/null +++ b/custom_components/babytracker/babytracker_api/client.py @@ -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) diff --git a/custom_components/babytracker/babytracker_api/models.py b/custom_components/babytracker/babytracker_api/models.py new file mode 100644 index 0000000..2f5919f --- /dev/null +++ b/custom_components/babytracker/babytracker_api/models.py @@ -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) diff --git a/custom_components/babytracker/babytracker_api/replica.py b/custom_components/babytracker/babytracker_api/replica.py new file mode 100644 index 0000000..d0a1658 --- /dev/null +++ b/custom_components/babytracker/babytracker_api/replica.py @@ -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 diff --git a/custom_components/babytracker/babytracker_api/seed.py b/custom_components/babytracker/babytracker_api/seed.py new file mode 100644 index 0000000..586c0c8 --- /dev/null +++ b/custom_components/babytracker/babytracker_api/seed.py @@ -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 diff --git a/custom_components/babytracker/calendar.py b/custom_components/babytracker/calendar.py new file mode 100644 index 0000000..f09668c --- /dev/null +++ b/custom_components/babytracker/calendar.py @@ -0,0 +1,49 @@ +"""A calendar per baby, showing the whole log.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant + +from .babytracker_api import Baby, Entry +from .coordinator import BabyTrackerCoordinator +from .entity import BabyEntity, async_add_baby_entities + +# Calendar events must have a duration, and most log entries are a moment in time. +_MINIMUM_DURATION = timedelta(minutes=1) + + +def _event(entry: Entry) -> CalendarEvent: + return CalendarEvent( + start=entry.time, + end=max(entry.end, entry.time + _MINIMUM_DURATION), + summary=entry.summary, + description=entry.note or None, + uid=entry.id, + ) + + +class BabyLogCalendar(BabyEntity, CalendarEntity): + def __init__(self, coordinator: BabyTrackerCoordinator, baby: Baby) -> None: + super().__init__(coordinator, baby, "log") + + @property + def event(self) -> CalendarEvent | None: + entry = self.coordinator.data.latest(self.baby_id) + return _event(entry) if entry else None + + async def async_get_events(self, hass: HomeAssistant, start_date: datetime, end_date: datetime) -> list[CalendarEvent]: + return [ + _event(entry) + for entry in self.coordinator.data.entries(self.baby_id) + if entry.time < end_date and max(entry.end, entry.time + _MINIMUM_DURATION) > start_date + ] + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + coordinator: BabyTrackerCoordinator = entry.runtime_data + async_add_baby_entities( + entry, coordinator, async_add_entities, lambda baby: [BabyLogCalendar(coordinator, baby)] + ) diff --git a/custom_components/babytracker/compat.py b/custom_components/babytracker/compat.py new file mode 100644 index 0000000..019832d --- /dev/null +++ b/custom_components/babytracker/compat.py @@ -0,0 +1,12 @@ +"""Schema library shared by this integration. + +Home Assistant replaced voluptuous with probatio, a drop-in with the same API. +Which one is installed depends on the Home Assistant version. +""" + +try: + import probatio as vol +except ImportError: + import voluptuous as vol + +__all__ = ["vol"] diff --git a/custom_components/babytracker/config_flow.py b/custom_components/babytracker/config_flow.py new file mode 100644 index 0000000..7d4e66b --- /dev/null +++ b/custom_components/babytracker/config_flow.py @@ -0,0 +1,88 @@ +"""Config flow for Baby Tracker.""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any, Mapping + +import requests + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.selector import TextSelector, TextSelectorConfig, TextSelectorType + +from .babytracker_api import AccountStateError, AuthenticationError, BabyTrackerClient, BabyTrackerError +from .compat import vol +from .const import CONF_DEVICE_UUID, DEVICE_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)) + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector(TextSelectorConfig(type=TextSelectorType.EMAIL)), + vol.Required(CONF_PASSWORD): _PASSWORD_SELECTOR, + } +) +REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): _PASSWORD_SELECTOR}) + + +class BabyTrackerConfigFlow(ConfigFlow, domain=DOMAIN): + VERSION = 1 + + async def _async_login(self, email: str, password: str, device_uuid: str) -> tuple[int | None, dict[str, str]]: + client = BabyTrackerClient( + email, password, device_uuid=device_uuid, device_name=DEVICE_NAME, device_os_info=DEVICE_NAME + ) + try: + return await self.hass.async_add_executor_job(client.login), {} + except AuthenticationError: + return None, {"base": "invalid_auth"} + except AccountStateError: + return None, {"base": "account_state"} + except (BabyTrackerError, requests.RequestException): + return None, {"base": "cannot_connect"} + except Exception: + _LOGGER.exception("Unexpected error logging in") + return None, {"base": "unknown"} + + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + errors: dict[str, str] = {} + if user_input is not None: + # This ID stays with the entry for good. The server treats each new + # value as a new device on the account. + device_uuid = str(uuid.uuid4()) + account_id, errors = await self._async_login( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD], device_uuid + ) + if not errors: + await self.async_set_unique_id(str(account_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={**user_input, CONF_DEVICE_UUID: device_uuid}, + ) + return self.async_show_form(step_id="user", data_schema=USER_SCHEMA, errors=errors) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult: + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + errors: dict[str, str] = {} + entry = self._get_reauth_entry() + if user_input is not None: + account_id, errors = await self._async_login( + entry.data[CONF_EMAIL], user_input[CONF_PASSWORD], entry.data[CONF_DEVICE_UUID] + ) + if not errors: + await self.async_set_unique_id(str(account_id)) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort(entry, data_updates=user_input) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + description_placeholders={"email": entry.data[CONF_EMAIL]}, + ) diff --git a/custom_components/babytracker/const.py b/custom_components/babytracker/const.py new file mode 100644 index 0000000..67c9d2a --- /dev/null +++ b/custom_components/babytracker/const.py @@ -0,0 +1,11 @@ +"""Constants for the Baby Tracker integration.""" + +from datetime import timedelta + +DOMAIN = "babytracker" + +CONF_DEVICE_UUID = "device_uuid" + +DEVICE_NAME = "Home Assistant" +UPDATE_INTERVAL = timedelta(minutes=5) +RECENT_ENTRIES = 20 diff --git a/custom_components/babytracker/coordinator.py b/custom_components/babytracker/coordinator.py new file mode 100644 index 0000000..53c151e --- /dev/null +++ b/custom_components/babytracker/coordinator.py @@ -0,0 +1,114 @@ +"""Keeps a local copy of the account in step with the server.""" + +from __future__ import annotations + +import asyncio +import binascii +import logging +import tempfile +from pathlib import Path +from typing import Any, Mapping + +import requests + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .babytracker_api import ( + AccountChangedError, + AuthenticationError, + BabyTrackerClient, + BabyTrackerError, + NotFoundError, + Replica, + Transaction, + extract_seed_db, + open_seed_db, + read_cursors, +) +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class BabyTrackerCoordinator(DataUpdateCoordinator[Replica]): + """Holds the account's data. + + The first refresh rebuilds everything from the server, and nothing about the + baby is written to disk. Later refreshes fetch only new log entries. + + Network work runs in the executor while the replica is read on the event + loop, so the executor only collects data and changes are applied on the loop. + The lock keeps requests and SyncID allocation in order. + """ + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client: BabyTrackerClient) -> None: + super().__init__(hass, _LOGGER, config_entry=entry, name=DOMAIN, update_interval=UPDATE_INTERVAL) + self.client = client + self._cursors: dict[str, int] = {} + self._lock = asyncio.Lock() + + async def _async_update_data(self) -> Replica: + try: + async with self._lock: + if self.data is None: + return await self._async_load() + return await self._async_pull() + except (AuthenticationError, AccountChangedError) as err: + raise ConfigEntryAuthFailed(str(err)) from err + except (BabyTrackerError, requests.RequestException, ValueError, KeyError) as err: + raise UpdateFailed(f"Could not sync with the Baby Tracker server: {err}") from err + + async def _async_load(self) -> Replica: + replica, cursors = await self.hass.async_add_executor_job(self._read_seed) + # The seed predates what this device uploaded, so its own log is needed too. + cursors, transactions = await self.hass.async_add_executor_job(self._fetch_new, cursors, True) + self._apply_all(replica, transactions) + self._cursors = cursors + return replica + + async def _async_pull(self) -> Replica: + cursors, transactions = await self.hass.async_add_executor_job(self._fetch_new, self._cursors, False) + self._apply_all(self.data, transactions) + self._cursors = cursors + return self.data + + def _read_seed(self) -> tuple[Replica, dict[str, int]]: + self.client.login() + with tempfile.TemporaryDirectory(prefix="babytracker") as folder: + try: + seed_zip = self.client.download_seed(Path(folder) / "seed.zip") + except NotFoundError: + _LOGGER.info("The account has no seed; using transaction logs only") + return Replica(), {} + connection = open_seed_db(extract_seed_db(seed_zip, Path(folder) / "seed.db")) + try: + return Replica.from_seed(connection), read_cursors(connection) + finally: + connection.close() + + def _fetch_new(self, cursors: Mapping[str, int], include_own: bool) -> tuple[dict[str, int], list[Transaction]]: + # Works on a copy, so a failure part way through repeats the whole fetch + # instead of skipping entries that were fetched but never applied. + updated = dict(cursors) + transactions = [item for _, item in self.client.pull(updated, include_own=include_own)] + return updated, transactions + + @staticmethod + def _apply_all(replica: Replica, transactions: list[Transaction]) -> None: + for transaction in transactions: + # One unreadable entry must not stop syncing for good. + try: + replica.apply(transaction.op, transaction.object) + except (ValueError, KeyError, TypeError, AttributeError, binascii.Error) as err: + _LOGGER.warning("Skipping unreadable log entry %s: %s", transaction.sync_id, err) + + async def async_add_record(self, record: Mapping[str, Any]) -> str: + """Upload a new record and show it right away. Returns its ID.""" + async with self._lock: + transaction = await self.hass.async_add_executor_job(self.client.add, record) + self._apply_all(self.data, [transaction]) + self.async_set_updated_data(self.data) + return record["objectID"] diff --git a/custom_components/babytracker/entity.py b/custom_components/babytracker/entity.py new file mode 100644 index 0000000..818cd06 --- /dev/null +++ b/custom_components/babytracker/entity.py @@ -0,0 +1,48 @@ +"""Base entity for a baby.""" + +from __future__ import annotations + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .babytracker_api import Baby +from .const import DOMAIN +from .coordinator import BabyTrackerCoordinator + + +class BabyEntity(CoordinatorEntity[BabyTrackerCoordinator]): + """An entity on a baby's device.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: BabyTrackerCoordinator, baby: Baby, key: str) -> None: + super().__init__(coordinator) + self.baby_id = baby.id + self._attr_unique_id = f"{baby.id}_{key}" + self._attr_translation_key = key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, baby.id)}, + name=baby.name, + manufacturer="Baby Tracker", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def available(self) -> bool: + return super().available and self.baby_id in self.coordinator.data.babies + + +def async_add_baby_entities(entry, coordinator: BabyTrackerCoordinator, async_add_entities, create) -> None: + """Add entities for each baby now, and for babies that appear later.""" + known: set[str] = set() + + @callback + def add_new() -> None: + new = [baby for baby_id, baby in coordinator.data.babies.items() if baby_id not in known] + if new: + known.update(baby.id for baby in new) + async_add_entities([entity for baby in new for entity in create(baby)]) + + add_new() + entry.async_on_unload(coordinator.async_add_listener(add_new)) diff --git a/custom_components/babytracker/icons.json b/custom_components/babytracker/icons.json new file mode 100644 index 0000000..20be3c3 --- /dev/null +++ b/custom_components/babytracker/icons.json @@ -0,0 +1,70 @@ +{ + "entity": { + "sensor": { + "last_diaper": { + "default": "mdi:human-baby-changing-table" + }, + "last_feeding": { + "default": "mdi:baby-bottle-outline" + }, + "last_sleep": { + "default": "mdi:sleep" + }, + "diapers_today": { + "default": "mdi:human-baby-changing-table" + }, + "feedings_today": { + "default": "mdi:baby-bottle-outline" + }, + "sleep_today": { + "default": "mdi:sleep" + }, + "bottle_volume_today": { + "default": "mdi:baby-bottle" + }, + "length": { + "default": "mdi:human-male-height" + }, + "head_circumference": { + "default": "mdi:tape-measure" + }, + "recent_activity": { + "default": "mdi:format-list-bulleted" + } + }, + "calendar": { + "log": { + "default": "mdi:baby-face-outline" + } + } + }, + "services": { + "log_diaper": { + "service": "mdi:human-baby-changing-table" + }, + "log_sleep": { + "service": "mdi:sleep" + }, + "log_bottle": { + "service": "mdi:baby-bottle" + }, + "log_nursing": { + "service": "mdi:mother-nurse" + }, + "log_temperature": { + "service": "mdi:thermometer" + }, + "log_growth": { + "service": "mdi:human-male-height" + }, + "log_bath": { + "service": "mdi:bathtub" + }, + "log_joy": { + "service": "mdi:emoticon-happy-outline" + }, + "log_journal": { + "service": "mdi:notebook-edit-outline" + } + } +} diff --git a/custom_components/babytracker/manifest.json b/custom_components/babytracker/manifest.json new file mode 100644 index 0000000..d8a4094 --- /dev/null +++ b/custom_components/babytracker/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "babytracker", + "name": "Baby Tracker", + "codeowners": ["@YOUR_GITHUB_USERNAME"], + "config_flow": true, + "documentation": "https://github.com/YOUR_GITHUB_USERNAME/babytracker-api", + "integration_type": "service", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/YOUR_GITHUB_USERNAME/babytracker-api/issues", + "requirements": ["requests>=2.25"], + "version": "0.1.0" +} diff --git a/custom_components/babytracker/sensor.py b/custom_components/babytracker/sensor.py new file mode 100644 index 0000000..9a9285d --- /dev/null +++ b/custom_components/babytracker/sensor.py @@ -0,0 +1,194 @@ +"""Sensors summarizing a baby's log.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength, UnitOfMass, UnitOfTemperature, UnitOfTime, UnitOfVolume +from homeassistant.util import dt as dt_util + +from .babytracker_api import BOTTLE_KINDS, FEED_KINDS, Baby, Entry, Replica +from .const import RECENT_ENTRIES +from .coordinator import BabyTrackerCoordinator +from .entity import BabyEntity, async_add_baby_entities + + +def _today(replica: Replica, baby_id: str, kinds: frozenset[str]) -> list[Entry]: + # An entry counts for the day it started, so a night's sleep counts for one day. + return replica.since(baby_id, dt_util.start_of_local_day(), kinds) + + +def _latest_measure(replica: Replica, baby_id: str, kind: str, key: str) -> float | None: + # Growth entries often hold only some measurements. + for entry in replica.entries(baby_id, {kind}): + if entry.details.get(key) is not None: + return entry.details[key] + return None + + +def _entry_attributes(entry: Entry | None) -> dict[str, Any]: + if entry is None: + return {} + return {"type": entry.kind, "summary": entry.summary, "note": entry.note, "end": entry.end, **entry.details} + + +@dataclass(frozen=True, kw_only=True) +class BabySensorDescription(SensorEntityDescription): + value_fn: Callable[[Replica, str], datetime | float | None] + entry_fn: Callable[[Replica, str], Entry | None] | None = None + + +def _last(kinds: frozenset[str]) -> dict[str, Any]: + def entry_fn(replica: Replica, baby_id: str) -> Entry | None: + return replica.latest(baby_id, kinds) + + def value_fn(replica: Replica, baby_id: str) -> datetime | None: + entry = entry_fn(replica, baby_id) + return entry.time if entry else None + + return {"device_class": SensorDeviceClass.TIMESTAMP, "value_fn": value_fn, "entry_fn": entry_fn} + + +def _count_today(kinds: frozenset[str]) -> Callable[[Replica, str], int]: + return lambda replica, baby_id: len(_today(replica, baby_id, kinds)) + + +DIAPER = frozenset({"Diaper"}) +SLEEP = frozenset({"Sleep"}) + +SENSORS = ( + BabySensorDescription(key="last_diaper", **_last(DIAPER)), + BabySensorDescription(key="last_feeding", **_last(FEED_KINDS)), + BabySensorDescription(key="last_sleep", **_last(SLEEP)), + BabySensorDescription( + key="diapers_today", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=_count_today(DIAPER), + ), + BabySensorDescription( + key="feedings_today", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=_count_today(FEED_KINDS), + ), + BabySensorDescription( + key="sleep_today", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.MINUTES, + value_fn=lambda replica, baby_id: sum( + entry.details.get("duration") or 0 for entry in _today(replica, baby_id, SLEEP) + ), + ), + BabySensorDescription( + key="bottle_volume_today", + device_class=SensorDeviceClass.VOLUME, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.MILLILITERS, + suggested_display_precision=0, + value_fn=lambda replica, baby_id: sum( + entry.details.get("volume_ml") or 0 for entry in _today(replica, baby_id, BOTTLE_KINDS) + ), + ), + BabySensorDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + value_fn=lambda replica, baby_id: _latest_measure(replica, baby_id, "Temperature", "celsius"), + ), + BabySensorDescription( + key="weight", + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, + value_fn=lambda replica, baby_id: _latest_measure(replica, baby_id, "Growth", "weight_kg"), + ), + BabySensorDescription( + key="length", + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + suggested_display_precision=1, + value_fn=lambda replica, baby_id: _latest_measure(replica, baby_id, "Growth", "length_cm"), + ), + BabySensorDescription( + key="head_circumference", + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + suggested_display_precision=1, + value_fn=lambda replica, baby_id: _latest_measure(replica, baby_id, "Growth", "head_cm"), + ), +) + + +class BabySensor(BabyEntity, SensorEntity): + entity_description: BabySensorDescription + + def __init__(self, coordinator: BabyTrackerCoordinator, baby: Baby, description: BabySensorDescription) -> None: + super().__init__(coordinator, baby, description.key) + self.entity_description = description + + @property + def native_value(self) -> datetime | float | None: + return self.entity_description.value_fn(self.coordinator.data, self.baby_id) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + entry_fn = self.entity_description.entry_fn + return _entry_attributes(entry_fn(self.coordinator.data, self.baby_id)) if entry_fn else None + + +class RecentActivitySensor(BabyEntity, SensorEntity): + """The latest entries of every type, for showing a log on a dashboard.""" + + _attr_device_class = SensorDeviceClass.TIMESTAMP + # A long list that changes constantly does not belong in the history database. + _unrecorded_attributes = frozenset({"entries"}) + + def __init__(self, coordinator: BabyTrackerCoordinator, baby: Baby) -> None: + super().__init__(coordinator, baby, "recent_activity") + + @property + def native_value(self) -> datetime | None: + entry = self.coordinator.data.latest(self.baby_id) + return entry.time if entry else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + entries = [] + for entry in self.coordinator.data.entries(self.baby_id): + if len(entries) == RECENT_ENTRIES: + break + entries.append( + { + "id": entry.id, + "type": entry.kind, + "summary": entry.summary, + "time": entry.time.isoformat(), + "note": entry.note, + } + ) + return {"entries": entries} + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + coordinator: BabyTrackerCoordinator = entry.runtime_data + + def create(baby: Baby) -> list[SensorEntity]: + sensors: list[SensorEntity] = [BabySensor(coordinator, baby, description) for description in SENSORS] + sensors.append(RecentActivitySensor(coordinator, baby)) + return sensors + + async_add_baby_entities(entry, coordinator, async_add_entities, create) diff --git a/custom_components/babytracker/services.py b/custom_components/babytracker/services.py new file mode 100644 index 0000000..3ea912e --- /dev/null +++ b/custom_components/babytracker/services.py @@ -0,0 +1,246 @@ +"""Actions that add entries to a baby's log.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import Any, Mapping + +import requests + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.util import dt as dt_util +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from .babytracker_api import ( + Baby, + BabyTrackerError, + DiaperAmount, + DiaperStatus, + NursingFinishSide, + bath, + diaper, + formula, + growth, + journal, + joy, + nursing, + pumped, + sleep, + temperature, +) +from .compat import vol +from .const import DOMAIN +from .coordinator import BabyTrackerCoordinator + +_WHO_AND_WHEN = { + vol.Optional("baby"): cv.string, + vol.Optional("time"): cv.datetime, +} +_BASE = {**_WHO_AND_WHEN, vol.Optional("note", default=""): cv.string} +_POSITIVE_FLOAT = vol.All(vol.Coerce(float), vol.Range(min=0)) +_MINUTES = vol.All(vol.Coerce(int), vol.Range(min=0)) + + +def _names(enum: Any, *excluded: str) -> list[str]: + return [member.name.lower() for member in enum if member.name.lower() not in excluded] + + +def _any_of(*keys: str) -> Callable[[dict], dict]: + def validate(data: dict) -> dict: + if not any(data.get(key) for key in keys): + raise vol.Invalid(f"at least one of {', '.join(keys)} is required") + return data + + return validate + + +def _english(hass: HomeAssistant, unit: str | None, english_unit: str) -> bool: + """Use the given unit, or else Home Assistant's unit system.""" + if unit is None: + return hass.config.units is US_CUSTOMARY_SYSTEM + return unit == english_unit + + +def _build_diaper(hass: HomeAssistant, baby: Mapping, data: Mapping, common: dict) -> dict: + amount = DiaperAmount[data["amount"].upper()] if "amount" in data else DiaperAmount.NOSET + return diaper(baby, DiaperStatus[data["status"].upper()], amount, **common) + + +def _build_sleep(hass: HomeAssistant, baby: Mapping, data: Mapping, common: dict) -> dict: + return sleep(baby, data["duration"], **common) + + +def _build_bottle(hass: HomeAssistant, baby: Mapping, data: Mapping, common: dict) -> dict: + build = pumped if data["kind"] == "pumped" else formula + return build(baby, data["amount"], english=_english(hass, data.get("unit"), "oz"), **common) + + +def _build_nursing(hass: HomeAssistant, baby: Mapping, data: Mapping, common: dict) -> dict: + return nursing( + baby, + data.get("left_minutes", 0), + data.get("right_minutes", 0), + data.get("both_minutes", 0), + NursingFinishSide[data["finish_side"].upper()] if "finish_side" in data else NursingFinishSide.NOT_SET, + **common, + ) + + +def _build_temperature(hass: HomeAssistant, baby: Mapping, data: Mapping, common: dict) -> dict: + return temperature(baby, data["value"], english=_english(hass, data.get("unit"), "F"), **common) + + +def _build_growth(hass: HomeAssistant, baby: Mapping, data: Mapping, common: dict) -> dict: + return growth( + baby, + data.get("weight"), + data.get("length"), + data.get("head"), + english=_english(hass, data.get("units"), "english"), + **common, + ) + + +def _build_bath(hass: HomeAssistant, baby: Mapping, data: Mapping, common: dict) -> dict: + return bath(baby, **common) + + +def _build_joy(hass: HomeAssistant, baby: Mapping, data: Mapping, common: dict) -> dict: + return joy(baby, **common) + + +def _build_journal(hass: HomeAssistant, baby: Mapping, data: Mapping, common: dict) -> dict: + return journal(baby, **common) + + +def _elapsed(data: Mapping) -> int: + return sum(data.get(key, 0) for key in ("duration", "left_minutes", "right_minutes", "both_minutes")) + + +_SERVICES: dict[str, tuple[Callable, Any]] = { + "log_diaper": ( + _build_diaper, + vol.Schema( + { + **_BASE, + vol.Required("status"): vol.In(_names(DiaperStatus)), + vol.Optional("amount"): vol.In(_names(DiaperAmount, "none", "noset")), + } + ), + ), + "log_sleep": (_build_sleep, vol.Schema({**_BASE, vol.Required("duration"): _MINUTES})), + "log_bottle": ( + _build_bottle, + vol.Schema( + { + **_BASE, + vol.Required("amount"): _POSITIVE_FLOAT, + vol.Optional("kind", default="formula"): vol.In(["formula", "pumped"]), + vol.Optional("unit"): vol.In(["ml", "oz"]), + } + ), + ), + "log_nursing": ( + _build_nursing, + vol.All( + vol.Schema( + { + **_BASE, + vol.Optional("left_minutes"): _MINUTES, + vol.Optional("right_minutes"): _MINUTES, + vol.Optional("both_minutes"): _MINUTES, + vol.Optional("finish_side"): vol.In(["left", "right"]), + } + ), + _any_of("left_minutes", "right_minutes", "both_minutes"), + ), + ), + "log_temperature": ( + _build_temperature, + vol.Schema({**_BASE, vol.Required("value"): vol.Coerce(float), vol.Optional("unit"): vol.In(["C", "F"])}), + ), + "log_growth": ( + _build_growth, + vol.All( + vol.Schema( + { + **_BASE, + vol.Optional("weight"): _POSITIVE_FLOAT, + vol.Optional("length"): _POSITIVE_FLOAT, + vol.Optional("head"): _POSITIVE_FLOAT, + vol.Optional("units"): vol.In(["metric", "english"]), + } + ), + _any_of("weight", "length", "head"), + ), + ), + "log_bath": (_build_bath, vol.Schema(_BASE)), + "log_joy": (_build_joy, vol.Schema(_BASE)), + "log_journal": ( + _build_journal, + vol.Schema({**_WHO_AND_WHEN, vol.Required("note"): vol.All(cv.string, vol.Length(min=1))}), + ), +} + + +def _coordinators(hass: HomeAssistant) -> list[BabyTrackerCoordinator]: + return [ + entry.runtime_data + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state is ConfigEntryState.LOADED + ] + + +def _resolve_baby(hass: HomeAssistant, wanted: str | None) -> tuple[BabyTrackerCoordinator, Baby]: + """Find a baby by device ID or name, or the only baby if none was named.""" + known = [(coordinator, baby) for coordinator in _coordinators(hass) for baby in coordinator.data.babies.values()] + if wanted is None: + if len(known) == 1: + return known[0] + raise ServiceValidationError(translation_domain=DOMAIN, translation_key="baby_required") + + baby_id = None + if (device := dr.async_get(hass).async_get(wanted)) is not None: + baby_id = next((key for domain, key in device.identifiers if domain == DOMAIN), None) + for coordinator, baby in known: + if baby.id == baby_id or baby.name.casefold() == wanted.casefold(): + return coordinator, baby + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="baby_not_found", translation_placeholders={"baby": wanted} + ) + + +def _common_fields(data: Mapping) -> dict[str, Any]: + """Fields every record shares. Without a time, the activity has just finished.""" + moment: datetime = ( + dt_util.as_utc(data["time"]) if "time" in data else dt_util.utcnow() - timedelta(minutes=_elapsed(data)) + ) + local = dt_util.as_local(moment) + offset = (local.utcoffset() or timedelta(0)) - (local.dst() or timedelta(0)) + return {"time": moment, "note": data.get("note", ""), "timezone": int(offset.total_seconds())} + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + def register(name: str, build: Callable, schema: Any) -> None: + async def handle(call: ServiceCall) -> ServiceResponse: + coordinator, baby = _resolve_baby(hass, call.data.get("baby")) + record = build(hass, baby.record, call.data, _common_fields(call.data)) + try: + record_id = await coordinator.async_add_record(record) + except (BabyTrackerError, requests.RequestException) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_failed", + translation_placeholders={"error": str(err)}, + ) from err + return {"id": record_id} if call.return_response else None + + hass.services.async_register(DOMAIN, name, handle, schema=schema, supports_response=SupportsResponse.OPTIONAL) + + for name, (build, schema) in _SERVICES.items(): + register(name, build, schema) diff --git a/custom_components/babytracker/services.yaml b/custom_components/babytracker/services.yaml new file mode 100644 index 0000000..bb2c1db --- /dev/null +++ b/custom_components/babytracker/services.yaml @@ -0,0 +1,156 @@ +.common: &common + baby: + selector: + device: + integration: babytracker + time: + selector: + datetime: + note: + selector: + text: + multiline: true + +log_diaper: + fields: + <<: *common + status: + required: true + default: wet + selector: + select: + options: [wet, poopy, mixed, dry] + amount: + selector: + select: + options: [light, normal, heavy, overflow] + +log_sleep: + fields: + <<: *common + duration: + required: true + selector: + number: + min: 0 + max: 1440 + unit_of_measurement: min + mode: box + +log_bottle: + fields: + <<: *common + amount: + required: true + selector: + number: + min: 0 + max: 2000 + step: any + mode: box + kind: + default: formula + selector: + select: + options: [formula, pumped] + unit: + selector: + select: + options: [ml, oz] + +log_nursing: + fields: + <<: *common + left_minutes: + selector: + number: + min: 0 + max: 300 + unit_of_measurement: min + mode: box + right_minutes: + selector: + number: + min: 0 + max: 300 + unit_of_measurement: min + mode: box + both_minutes: + selector: + number: + min: 0 + max: 300 + unit_of_measurement: min + mode: box + finish_side: + selector: + select: + options: [left, right] + +log_temperature: + fields: + <<: *common + value: + required: true + selector: + number: + min: 0 + max: 120 + step: 0.1 + mode: box + unit: + selector: + select: + options: ["C", "F"] + +log_growth: + fields: + <<: *common + weight: + selector: + number: + min: 0 + max: 200 + step: any + mode: box + length: + selector: + number: + min: 0 + max: 300 + step: any + mode: box + head: + selector: + number: + min: 0 + max: 100 + step: any + mode: box + units: + selector: + select: + options: [metric, english] + +log_bath: + fields: + <<: *common + +log_joy: + fields: + <<: *common + +log_journal: + fields: + baby: + selector: + device: + integration: babytracker + time: + selector: + datetime: + note: + required: true + selector: + text: + multiline: true diff --git a/custom_components/babytracker/translations/en.json b/custom_components/babytracker/translations/en.json new file mode 100644 index 0000000..6e5fa59 --- /dev/null +++ b/custom_components/babytracker/translations/en.json @@ -0,0 +1,316 @@ +{ + "config": { + "step": { + "user": { + "title": "Baby Tracker", + "description": "Sign in with the sync group's email and password, as set in the app's sync settings. Home Assistant joins the group as one more device.", + "data": { + "email": "Email", + "password": "Password" + } + }, + "reauth_confirm": { + "title": "Sign in again", + "description": "The password for {email} is no longer accepted.", + "data": { + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "The group doesn't exist or the password is incorrect.", + "account_state": "The server refused this account's login (HTTP 307). Check the group in the app.", + "cannot_connect": "Could not reach the Baby Tracker server.", + "unknown": "Unexpected error. Check the logs." + }, + "abort": { + "already_configured": "This account is already set up.", + "reauth_successful": "Signed in again.", + "wrong_account": "That login belongs to a different account." + } + }, + "entity": { + "sensor": { + "last_diaper": { + "name": "Last diaper" + }, + "last_feeding": { + "name": "Last feeding" + }, + "last_sleep": { + "name": "Last sleep" + }, + "diapers_today": { + "name": "Diapers today" + }, + "feedings_today": { + "name": "Feedings today" + }, + "sleep_today": { + "name": "Sleep today" + }, + "bottle_volume_today": { + "name": "Bottle volume today" + }, + "temperature": { + "name": "Temperature" + }, + "weight": { + "name": "Weight" + }, + "length": { + "name": "Length" + }, + "head_circumference": { + "name": "Head circumference" + }, + "recent_activity": { + "name": "Recent activity" + } + }, + "calendar": { + "log": { + "name": "Log" + } + } + }, + "services": { + "log_diaper": { + "name": "Log diaper", + "description": "Adds a diaper change.", + "fields": { + "baby": { + "name": "Baby", + "description": "The baby's device, or their name. Can be left out if the account has one baby." + }, + "time": { + "name": "Time", + "description": "When it started. Defaults to having just finished." + }, + "note": { + "name": "Note", + "description": "Optional text to attach." + }, + "status": { + "name": "Status", + "description": "What was in the diaper." + }, + "amount": { + "name": "Amount", + "description": "How much. Can be left out." + } + } + }, + "log_sleep": { + "name": "Log sleep", + "description": "Adds a sleep.", + "fields": { + "baby": { + "name": "Baby", + "description": "The baby's device, or their name. Can be left out if the account has one baby." + }, + "time": { + "name": "Time", + "description": "When it started. Defaults to having just finished." + }, + "note": { + "name": "Note", + "description": "Optional text to attach." + }, + "duration": { + "name": "Duration", + "description": "Length in minutes." + } + } + }, + "log_bottle": { + "name": "Log bottle", + "description": "Adds a bottle feed.", + "fields": { + "baby": { + "name": "Baby", + "description": "The baby's device, or their name. Can be left out if the account has one baby." + }, + "time": { + "name": "Time", + "description": "When it started. Defaults to having just finished." + }, + "note": { + "name": "Note", + "description": "Optional text to attach." + }, + "amount": { + "name": "Amount", + "description": "Volume in the chosen unit." + }, + "kind": { + "name": "Kind", + "description": "What was in the bottle." + }, + "unit": { + "name": "Unit", + "description": "Defaults to Home Assistant's unit system." + } + } + }, + "log_nursing": { + "name": "Log nursing", + "description": "Adds a nursing session. Give at least one duration.", + "fields": { + "baby": { + "name": "Baby", + "description": "The baby's device, or their name. Can be left out if the account has one baby." + }, + "time": { + "name": "Time", + "description": "When it started. Defaults to having just finished." + }, + "note": { + "name": "Note", + "description": "Optional text to attach." + }, + "left_minutes": { + "name": "Left", + "description": "Minutes on the left side." + }, + "right_minutes": { + "name": "Right", + "description": "Minutes on the right side." + }, + "both_minutes": { + "name": "Both", + "description": "Minutes on both sides." + }, + "finish_side": { + "name": "Finish side", + "description": "The side finished on." + } + } + }, + "log_temperature": { + "name": "Log temperature", + "description": "Adds a temperature reading.", + "fields": { + "baby": { + "name": "Baby", + "description": "The baby's device, or their name. Can be left out if the account has one baby." + }, + "time": { + "name": "Time", + "description": "When it started. Defaults to having just finished." + }, + "note": { + "name": "Note", + "description": "Optional text to attach." + }, + "value": { + "name": "Temperature", + "description": "The reading." + }, + "unit": { + "name": "Unit", + "description": "Defaults to Home Assistant's unit system." + } + } + }, + "log_growth": { + "name": "Log growth", + "description": "Adds growth measurements. Give at least one.", + "fields": { + "baby": { + "name": "Baby", + "description": "The baby's device, or their name. Can be left out if the account has one baby." + }, + "time": { + "name": "Time", + "description": "When it started. Defaults to having just finished." + }, + "note": { + "name": "Note", + "description": "Optional text to attach." + }, + "weight": { + "name": "Weight", + "description": "In kg, or lb for English units." + }, + "length": { + "name": "Length", + "description": "In cm, or inches for English units." + }, + "head": { + "name": "Head circumference", + "description": "In cm, or inches for English units." + }, + "units": { + "name": "Units", + "description": "Defaults to Home Assistant's unit system." + } + } + }, + "log_bath": { + "name": "Log bath", + "description": "Adds a bath.", + "fields": { + "baby": { + "name": "Baby", + "description": "The baby's device, or their name. Can be left out if the account has one baby." + }, + "time": { + "name": "Time", + "description": "When it started. Defaults to having just finished." + }, + "note": { + "name": "Note", + "description": "Optional text to attach." + } + } + }, + "log_joy": { + "name": "Log joy", + "description": "Adds a joyful moment.", + "fields": { + "baby": { + "name": "Baby", + "description": "The baby's device, or their name. Can be left out if the account has one baby." + }, + "time": { + "name": "Time", + "description": "When it started. Defaults to having just finished." + }, + "note": { + "name": "Note", + "description": "Optional text to attach." + } + } + }, + "log_journal": { + "name": "Log journal entry", + "description": "Adds a journal entry.", + "fields": { + "baby": { + "name": "Baby", + "description": "The baby's device, or their name. Can be left out if the account has one baby." + }, + "time": { + "name": "Time", + "description": "When it started. Defaults to having just finished." + }, + "note": { + "name": "Text", + "description": "The journal text." + } + } + } + }, + "exceptions": { + "baby_required": { + "message": "Choose a baby. The account doesn't have exactly one." + }, + "baby_not_found": { + "message": "No baby found matching {baby}." + }, + "upload_failed": { + "message": "The entry wasn't saved, because uploading it failed: {error}" + } + } +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..9286e75 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,234 @@ +# Baby Tracker sync protocol + +Derived from Baby Tracker for Android 4.53, mainly `sync/SyncImple`, `utility/GsonUtils` +and `database/BTDatabaseImple`. Nothing here has been observed on the wire. It is what +the app's code sends and expects. [Open questions](#open-questions) lists what the code +cannot settle. + +## Conventions + +- Base URL: `https://prodapp.babytrackers.com` +- Auth: a session cookie set by `POST /session`. No tokens or request signing. +- JSON requests set `Content-Type: application/json` and `charset: utf-8`. +- On a 401, the app logs in again and retries once. +- After a re-login, the app compares `AccountID` with the stored one and turns sync off + if they differ. + +## Endpoints + +| Method | Path | Body | Success | Other statuses the app handles | +|---|---|---|---|---| +| POST | `/session` | UserInfo | 200, `{"AccountID": n}` | 400/401 bad login, 307, 409 | +| DELETE | `/session` | | 200 | 401 | +| POST | `/account/forgot_password` | see below | 200 | 409 | +| POST | `/device_token` | `{"TokenNative": "...", "TokenAppType": 5}` | 200 or 201 | 401, 408 | +| GET | `/account/device` | | 200, `[DeviceSyncInfo]` | 401 | +| GET | `/account/transaction/{deviceUUID}/{lastSyncID}` | | 200, `[TransactionItem]` | 401 | +| POST | `/account/transaction` | TransactionItem | 201 | 401 | +| GET | `/account/seed` | | 200, zip file | 401, 404 no seed | +| GET | `/account/picture/{fileName}` | | 200, file | 401, 404 | +| POST | `/account/picture` | multipart `picture` | 201 | 401, 408 | +| POST | `/account/clonedata` | multipart `clonedata` | 201 | 401, 408 | +| POST | `/staging_account` | UserInfo | 200 | 400/401, 409 | +| POST | `/staging_account/seed` | multipart `seed` | 201 | 401, 408 | +| POST | `/staging_account/picture` | multipart `picture` | 201 | 401, 408 | +| PUT | `/staging_account` | | 200, `{"AccountID": n}` | 401, 408, 409, 424 no seed | +| POST | `/account_reset` | UserInfo | 200 | 400/401, 409 | + +### Request bodies + +```jsonc +// UserInfo +{ + "EmailAddress": "you@example.com", + "Password": "plain text, protected only by TLS", + "Device": { + "DeviceUUID": "random UUID", + "DeviceName": "free text", + "DeviceOSInfo": " OS " + }, + "AppInfo": {"AccountType": 0, "AppType": 0} +} + +// forgot_password. Language is Locale.getLanguage(), such as "en". +{"EmailAddress": "...", "Language": "en", "AppInfo": {"AccountType": 0, "AppType": 0}} + +// DeviceSyncInfo +{"DeviceUUID": "...", "LastSyncID": 123} + +// TransactionItem. Transaction is base64 of a record's JSON. +{"SyncID": 123, "OPCode": "0", "Transaction": "ewog..."} +``` + +### Multipart format + +The boundaries are fixed, and each body starts with a CRLF: + +- `__BABY_TRACKER_BOUNDARY____Photo__`, for pictures and clone data +- `__BABY_TRACKER_BOUNDARY____SEED__`, for seeds + +```text +\r\n--\r\n +Content-Disposition: form-data; name="picture"; filename="abc.jpg"\r\n +Content-Type: image/jpeg\r\n\r\n +\r\n----\r\n +``` + +Seeds use `name="seed"; filename="seed.zip"`. Clone data uses `name="clonedata"` with +the device ID in the file name. Both have `Content-Type: application/zip`. + +## Flows + +**Sync** +1. `GET /account/device`. +2. For each device other than this one, compare `LastSyncID` with the locally merged + value (table `MergedTransaction`). If behind, or unknown, start from the local value + or from 0. +3. `GET /account/transaction/{device}/{syncID}` and merge. Repeat from the last + `SyncID` while a page has at least **500** entries. +4. Upload this device's pending entries oldest first, one `POST` each, and delete each + after its 201. + +**Join a group** +1. Reset the local device ID. +2. `POST /session`. A 400 or 401 means the group does not exist or the password is wrong. +3. Upload the old local database as clone data. The app continues if this fails. +4. `GET /account/seed`, unzip it, and replace the local database with `EasyLog.db`. +5. Download pictures and thumbnails, then run a sync. + +**Create a group** +`POST /staging_account`, then `POST /staging_account/seed`, then each picture to +`/staging_account/picture`, then `PUT /staging_account`. + +**Reset a group** +As create, but starting with `POST /account_reset`. + +## Records + +The decoded `Transaction` value is JSON written by Gson, then UTF-8, then standard +base64 with padding and no line breaks. + +- **`BCObjectType`**: the class name, such as `"Diaper"`. It appears at the top level + only. Nested objects such as `baby` do not have it. +- **Dates**: `"yyyy-MM-dd HH:mm:ss Z"` in GMT, such as `"2026-11-04 08:30:00 +0000"`. + Parsers must also accept `"yyyy-MM-dd hh:mm:ss a Z"`, including localized AM/PM + markers such as `nachm.` or `午後`, because the app does. +- **Enums**: strings holding a number, such as `"1"`. +- **Nulls**: omitted. +- **Durations**: whole minutes. +- **`objectID`**: a random UUID. +- **`timestamp`**: last modification time. +- **`time`**: when the activity happened. +- **`timezone`**: UTC offset in seconds, excluding DST. +- **`newFlage`**: spelled that way. Merging ignores the incoming value. + +An update or delete carries the whole record. The app applies records in log order, with +no timestamp comparison in the activity merge path, so the last one merged wins. `Replica` +instead ignores a change older than the one it holds, because it fetches logs device by +device when rebuilding from the seed. + +### Common fields + +| Class | Parent | Fields | +|---|---|---| +| BCObject | | `objectID`, `timestamp`, `newFlage` | +| Activity | BCObject | `time`, `timezone`, `note`, `pictureLoaded`, `pictureNote` (list of Picture) | +| BabyActivity | Activity | `baby` | +| Baby | BCObject | `name`, `dob`, `dueDay`, `gender` (true is boy), `pictureName` | +| Picture | BCObject | `fileName`, `activityID`, `attached`, `deleted`, `mediaType` | + +### Activities + +| Class | Parent | Fields | +|---|---|---| +| Diaper | BabyActivity | `status`, `amount` | +| Sleep | BabyActivity | `duration` | +| Bath, Joy, Journal | BabyActivity | none | +| Growth | BabyActivity | `weight`, `length`, `head`, `deleted`, `queryIndex` | +| Temperature | BabyActivity | `temperature` | +| Medication | BabyActivity | `medicationSelection`, `amount` (float) | +| Vaccine | BabyActivity | `vaccineType` | +| Milestone | BabyActivity | `milestoneType`, `hasNewType` | +| OtherActivity | BabyActivity | `desc`, `duration` | +| Feed | BabyActivity | none | +| Formula, Pumped | Bottle, which is a Feed | `amount` | +| OtherFeed | Feed | `feedType`, `desc`, `amount`, `unit` | +| Nursing | Feed | `leftDuration`, `rightDuration`, `bothDuration`, `finishSide` | +| NursingSession | Nursing | `state`, `pairSessionState`, `detailed`, `leftTimestamps`, `rightTimestamps` | +| Pump | Activity (no baby) | `amount`, `leftAmount`, `rightAmount`, `leftDuration`, `rightDuration`, `sides`, `finishSide`, `label` | + +### Selections + +| Class | Parent | Fields | +|---|---|---| +| EditableSelection | BCObject | `name`, `desc` | +| OtherFeedSelection | EditableSelection | `bottle` | +| MedicationSelection | EditableSelection | `prescription`, `amountPerTime`, `interval`, `unit` | +| MilestoneSelection | EditableSelection | `babyID`, `forAge`, `used` | +| VaccineSelection | EditableSelection | `forAge` | +| OtherActivityDescription | EditableSelection | none | + +`ConflictRecord` holds `winner` and `loser`. Each is a full record, with its own +`BCObjectType`. + +### Measures + +A measure is `{"englishMeasure": bool, "value": float}`. + +| Measure | Metric | English | +|---|---|---| +| Volume | ml | fl oz | +| Weight | kg | lb | +| Length | cm | in | +| Temperature | °C | °F | + +### Enums + +| Enum | Values | +|---|---| +| OPCode | 0 insert, 1 update, 2 delete, 3 relive, 4 conflict | +| DiaperStatus | 0 wet, 1 poopy, 2 mixed, 3 dry | +| DiaperAmount | 0 none, 1 light, 2 normal, 3 heavy, 4 overflow, 5 not set | +| NursingFinishSide | 0 not set, 1 left, 2 right | +| PumpFinishSide | 0 none, 1 left, 2 right | +| PumpSides | 0 both, 1 left, 2 right | +| MediaType | 0 picture, 1 video, 2 audio | +| NursingSessionState | 0 start, 1 left running, 2 right running, 3 left paused, 4 right paused, 5 both stopped, 6 end | + +The app's statistics count a diaper with status wet and amount none as **dry**. The +`diaper()` helper therefore defaults to amount "not set". + +### Pictures and seeds + +- A `Picture` record's file is `.jpg` for a picture, `.mp4` for a video + and plain `` for audio. Its thumbnail is `__thumbnail.jpg`. +- A baby's picture is named `.jpg`. +- Upload the picture files as well as the record that refers to them. +- The seed zip contains the SQLite database `EasyLog.db`. Times in it are Unix seconds + stored as floats. `MergedTransaction(DeviceID, SyncID)` says how far each device's log + is already included. + +## Open questions + +The app's code does not settle these. The client's choice is noted where it made one. + +| Question | Client's behaviour | +|---|---| +| Does login add the device to the account? | Assumed yes. Keep `device_uuid` stable. | +| Is `lastSyncID` exclusive? The app passes its last merged ID and logs "got old sync item" for repeats. | Assumed exclusive. Entries at or below the cursor are dropped, so an inclusive server is also safe. | +| Does `LastSyncID` include a device's own uploads? The app counts SyncIDs locally, in an autoincrement table. | Assumed yes. The next SyncID is `LastSyncID + 1`, then counted locally. The Home Assistant integration also relies on this to find its own log after a restart. | +| What does the server do with a repeated or out-of-order SyncID? | Unknown. Errors are raised to the caller. | +| What does 307 on login mean? The app stores the credentials, returns internal code 6001 and turns sync off. No UI code handles 6001 specifically. | Raises `AccountStateError`. | +| What does 409 mean on login and `/account_reset`? On `/staging_account` it most likely means the email is taken. | Raises `ConflictError`. | +| Are there other response fields? The app ignores anything it does not declare. | Ignored, except for transaction payloads, which are kept whole. | +| Must a nested `baby` be complete, or is `objectID` enough? Android stores only the ID, but iOS clients may differ. | Sends the full baby record you pass in. | +| How do other platforms handle records? The server may also serve an iOS app, whose records could have types missing from `GsonUtils`. | Unknown types decode normally. Android would reject them, so do not upload them to a group with Android devices. | + +## Security notes + +- Certificates are validated normally. `SyncImple.trustAllHosts()` exists but nothing + calls it. +- The app stores the account password in shared preferences, DES encrypted with a key + hardcoded in the APK. Anyone with access to the app's preferences can recover it. +- The server takes the password itself rather than a hash of it, so TLS is its only + protection in transit. diff --git a/examples/smoke_test.py b/examples/smoke_test.py new file mode 100644 index 0000000..aa7822e --- /dev/null +++ b/examples/smoke_test.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Read-only check of the client against the real server, using your own account. + + BABYTRACKER_EMAIL=you@example.com BABYTRACKER_PASSWORD=... python3 examples/smoke_test.py + +It logs in, lists devices and counts transactions per device, and downloads the +seed to a temporary folder to count babies. It uploads nothing, but logging in +registers this client as a device on the account. The device ID is kept in +~/.config/babytracker-api/device-id so repeat runs reuse that one device. + +Output is limited to counts and record types, so it is safe to share. +""" + +import collections +import importlib.util +import os +import sys +import tempfile +import uuid +from pathlib import Path + +# Loaded by path: the integration folder holds a calendar.py, so putting that +# folder on sys.path would hide Python's own calendar module. +_LIBRARY = Path(__file__).resolve().parent.parent / "custom_components" / "babytracker" / "babytracker_api" +_spec = importlib.util.spec_from_file_location( + "babytracker_api", _LIBRARY / "__init__.py", submodule_search_locations=[str(_LIBRARY)] +) +_module = importlib.util.module_from_spec(_spec) +sys.modules["babytracker_api"] = _module +_spec.loader.exec_module(_module) + +from babytracker_api import BabyTrackerClient, extract_seed_db, open_seed_db, read_babies, read_cursors + + +def device_id() -> str: + path = Path.home() / ".config" / "babytracker-api" / "device-id" + if path.exists(): + return path.read_text().strip() + path.parent.mkdir(parents=True, exist_ok=True) + value = str(uuid.uuid4()) + path.write_text(value) + return value + + +def main() -> None: + client = BabyTrackerClient( + os.environ["BABYTRACKER_EMAIL"], os.environ["BABYTRACKER_PASSWORD"], device_uuid=device_id() + ) + client.login() + print("login: ok") + + devices = client.get_devices() + print(f"devices: {len(devices)}") + for number, device in enumerate(devices, 1): + own = " (this client)" if device.uuid == client.device_uuid else "" + print(f" device {number}: last SyncID {device.last_sync_id}{own}") + + with tempfile.TemporaryDirectory() as folder: + seed = client.download_seed(Path(folder) / "seed.zip") + connection = open_seed_db(extract_seed_db(seed, Path(folder) / "seed.db")) + print(f"seed: {len(read_babies(connection))} babies, {len(read_cursors(connection))} merged device cursors") + cursors = read_cursors(connection) + connection.close() + + types = collections.Counter() + ops = collections.Counter() + for _, item in client.pull(cursors): + types[item.object_type] += 1 + ops[item.op.name] += 1 + print("transactions newer than the seed:", sum(types.values())) + print(" by type:", dict(types)) + print(" by operation:", dict(ops)) + + +if __name__ == "__main__": + main() diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..a41ba5f --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Baby Tracker", + "homeassistant": "2025.1.0", + "render_readme": true +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0ad7cc1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "babytracker-api" +version = "0.1.0" +description = "Unofficial Python client for the Baby Tracker sync server" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["requests>=2.25"] + +# The library lives inside the integration because HACS installs only that folder. +[tool.setuptools] +packages = ["babytracker_api"] +package-dir = {"babytracker_api" = "custom_components/babytracker/babytracker_api"} diff --git a/tests/ha_stubs/README.md b/tests/ha_stubs/README.md new file mode 100644 index 0000000..e39253b --- /dev/null +++ b/tests/ha_stubs/README.md @@ -0,0 +1,5 @@ +A minimal stand-in for the parts of Home Assistant this integration uses, so its +code can run in tests without installing Home Assistant. + +It only shows that the integration's own logic works against these fakes. It does not +show that the integration works in real Home Assistant. diff --git a/tests/ha_stubs/homeassistant/__init__.py b/tests/ha_stubs/homeassistant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ha_stubs/homeassistant/components/__init__.py b/tests/ha_stubs/homeassistant/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ha_stubs/homeassistant/components/calendar/__init__.py b/tests/ha_stubs/homeassistant/components/calendar/__init__.py new file mode 100644 index 0000000..b6c3368 --- /dev/null +++ b/tests/ha_stubs/homeassistant/components/calendar/__init__.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from ...exceptions import HomeAssistantError + + +@dataclass +class CalendarEvent: + start: datetime + end: datetime + summary: str + description: Optional[str] = None + uid: Optional[str] = None + + def __post_init__(self): + if self.start.tzinfo is None or self.end.tzinfo is None: + raise HomeAssistantError("Expected all values to have a timezone") + if self.end <= self.start: + raise HomeAssistantError("Expected end time to be after start time") + + +class CalendarEntity: + pass diff --git a/tests/ha_stubs/homeassistant/components/sensor/__init__.py b/tests/ha_stubs/homeassistant/components/sensor/__init__.py new file mode 100644 index 0000000..40bb612 --- /dev/null +++ b/tests/ha_stubs/homeassistant/components/sensor/__init__.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Any, Optional + + +class SensorDeviceClass(str, Enum): + DISTANCE = "distance" + DURATION = "duration" + TEMPERATURE = "temperature" + TIMESTAMP = "timestamp" + VOLUME = "volume" + WEIGHT = "weight" + + +class SensorStateClass(str, Enum): + MEASUREMENT = "measurement" + TOTAL_INCREASING = "total_increasing" + + +@dataclass(frozen=True, kw_only=True) +class SensorEntityDescription: + key: str + device_class: Optional[SensorDeviceClass] = None + state_class: Optional[SensorStateClass] = None + native_unit_of_measurement: Optional[Any] = None + suggested_display_precision: Optional[int] = None + + +class SensorEntity: + pass diff --git a/tests/ha_stubs/homeassistant/config_entries.py b/tests/ha_stubs/homeassistant/config_entries.py new file mode 100644 index 0000000..f75fdb5 --- /dev/null +++ b/tests/ha_stubs/homeassistant/config_entries.py @@ -0,0 +1,63 @@ +from enum import Enum +from typing import Generic, TypeVar + +from .data_entry_flow import AbortFlow + +_T = TypeVar("_T") +ConfigFlowResult = dict + + +class ConfigEntryState(Enum): + LOADED = "loaded" + NOT_LOADED = "not_loaded" + + +class ConfigEntry(Generic[_T]): + def __init__(self, data, unique_id, title="entry"): + self.data = dict(data) + self.unique_id = unique_id + self.title = title + self.state = ConfigEntryState.NOT_LOADED + self.runtime_data = None + self.unloads = [] + + def async_on_unload(self, func): + self.unloads.append(func) + + +class ConfigFlow: + def __init_subclass__(cls, domain=None, **kwargs): + super().__init_subclass__(**kwargs) + cls.domain = domain + + def __init__(self): + self.hass = None + self.unique_id = None + self.existing_unique_ids = set() + self.reauth_entry = None + self.updated = None + + def async_show_form(self, step_id, data_schema=None, errors=None, description_placeholders=None): + return {"type": "form", "step_id": step_id, "errors": errors or {}, "schema": data_schema, + "description_placeholders": description_placeholders} + + def async_create_entry(self, title, data): + return {"type": "create_entry", "title": title, "data": data} + + async def async_set_unique_id(self, unique_id): + self.unique_id = unique_id + + def _abort_if_unique_id_configured(self): + if self.unique_id in self.existing_unique_ids: + raise AbortFlow("already_configured") + + def _abort_if_unique_id_mismatch(self, reason="unique_id_mismatch"): + if self.reauth_entry.unique_id != self.unique_id: + raise AbortFlow(reason) + + def _get_reauth_entry(self): + return self.reauth_entry + + def async_update_reload_and_abort(self, entry, data_updates=None): + entry.data.update(data_updates or {}) + return {"type": "abort", "reason": "reauth_successful"} diff --git a/tests/ha_stubs/homeassistant/const.py b/tests/ha_stubs/homeassistant/const.py new file mode 100644 index 0000000..d2d340f --- /dev/null +++ b/tests/ha_stubs/homeassistant/const.py @@ -0,0 +1,29 @@ +from enum import Enum + +CONF_EMAIL = "email" +CONF_PASSWORD = "password" + + +class Platform(str, Enum): + CALENDAR = "calendar" + SENSOR = "sensor" + + +class UnitOfLength(str, Enum): + CENTIMETERS = "cm" + + +class UnitOfMass(str, Enum): + KILOGRAMS = "kg" + + +class UnitOfTemperature(str, Enum): + CELSIUS = "°C" + + +class UnitOfTime(str, Enum): + MINUTES = "min" + + +class UnitOfVolume(str, Enum): + MILLILITERS = "mL" diff --git a/tests/ha_stubs/homeassistant/core.py b/tests/ha_stubs/homeassistant/core.py new file mode 100644 index 0000000..f95fca1 --- /dev/null +++ b/tests/ha_stubs/homeassistant/core.py @@ -0,0 +1,50 @@ +import asyncio +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional + +ServiceResponse = Optional[dict] + + +def callback(func): + return func + + +class SupportsResponse(Enum): + NONE = "none" + OPTIONAL = "optional" + ONLY = "only" + + +@dataclass +class ServiceCall: + data: dict = field(default_factory=dict) + return_response: bool = False + + +class _Services: + def __init__(self): + self.registered = {} + + def async_register(self, domain, service, handler, schema=None, supports_response=SupportsResponse.NONE): + self.registered[(domain, service)] = (handler, schema, supports_response) + + async def async_call(self, domain, service, data=None, return_response=False): + handler, schema, _ = self.registered[(domain, service)] + validated = schema(dict(data or {})) if schema else dict(data or {}) + return await handler(ServiceCall(validated, return_response)) + + +class _Config: + def __init__(self, units): + self.units = units + + +class HomeAssistant: + def __init__(self, units): + self.config = _Config(units) + self.services = _Services() + self.config_entries = None + + async def async_add_executor_job(self, target, *args): + return await asyncio.get_running_loop().run_in_executor(None, target, *args) diff --git a/tests/ha_stubs/homeassistant/data_entry_flow.py b/tests/ha_stubs/homeassistant/data_entry_flow.py new file mode 100644 index 0000000..7e4dcb5 --- /dev/null +++ b/tests/ha_stubs/homeassistant/data_entry_flow.py @@ -0,0 +1,4 @@ +class AbortFlow(Exception): + def __init__(self, reason): + super().__init__(reason) + self.reason = reason diff --git a/tests/ha_stubs/homeassistant/exceptions.py b/tests/ha_stubs/homeassistant/exceptions.py new file mode 100644 index 0000000..11e9eca --- /dev/null +++ b/tests/ha_stubs/homeassistant/exceptions.py @@ -0,0 +1,18 @@ +class HomeAssistantError(Exception): + def __init__(self, *args, translation_domain=None, translation_key=None, translation_placeholders=None): + super().__init__(*args) + self.translation_domain = translation_domain + self.translation_key = translation_key + self.translation_placeholders = translation_placeholders + + +class ServiceValidationError(HomeAssistantError): + pass + + +class ConfigEntryAuthFailed(HomeAssistantError): + pass + + +class ConfigEntryNotReady(HomeAssistantError): + pass diff --git a/tests/ha_stubs/homeassistant/helpers/__init__.py b/tests/ha_stubs/homeassistant/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ha_stubs/homeassistant/helpers/config_validation.py b/tests/ha_stubs/homeassistant/helpers/config_validation.py new file mode 100644 index 0000000..1c1aee4 --- /dev/null +++ b/tests/ha_stubs/homeassistant/helpers/config_validation.py @@ -0,0 +1,22 @@ +from datetime import datetime as datetime_sys + +import voluptuous + + +def string(value): + if value is None: + raise voluptuous.Invalid("string value is None") + return str(value) + + +def datetime(value): + if isinstance(value, datetime_sys): + return value + try: + return datetime_sys.fromisoformat(value) + except (TypeError, ValueError) as err: + raise voluptuous.Invalid("invalid datetime") from err + + +def config_entry_only_config_schema(domain): + return lambda config: config diff --git a/tests/ha_stubs/homeassistant/helpers/device_registry.py b/tests/ha_stubs/homeassistant/helpers/device_registry.py new file mode 100644 index 0000000..e3e2285 --- /dev/null +++ b/tests/ha_stubs/homeassistant/helpers/device_registry.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from enum import Enum + + +class DeviceEntryType(Enum): + SERVICE = "service" + + +def DeviceInfo(**kwargs): + return kwargs + + +@dataclass +class DeviceEntry: + identifiers: set + + +class DeviceRegistry: + def __init__(self): + self.devices = {} + + def async_get(self, device_id): + return self.devices.get(device_id) + + +REGISTRY = DeviceRegistry() + + +def async_get(hass): + return REGISTRY diff --git a/tests/ha_stubs/homeassistant/helpers/selector.py b/tests/ha_stubs/homeassistant/helpers/selector.py new file mode 100644 index 0000000..41971b7 --- /dev/null +++ b/tests/ha_stubs/homeassistant/helpers/selector.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class TextSelectorType(str, Enum): + EMAIL = "email" + PASSWORD = "password" + + +def TextSelectorConfig(**kwargs): + return kwargs + + +class TextSelector: + def __init__(self, config=None): + self.config = config + + def __call__(self, value): + return str(value) diff --git a/tests/ha_stubs/homeassistant/helpers/typing.py b/tests/ha_stubs/homeassistant/helpers/typing.py new file mode 100644 index 0000000..43a7299 --- /dev/null +++ b/tests/ha_stubs/homeassistant/helpers/typing.py @@ -0,0 +1 @@ +ConfigType = dict diff --git a/tests/ha_stubs/homeassistant/helpers/update_coordinator.py b/tests/ha_stubs/homeassistant/helpers/update_coordinator.py new file mode 100644 index 0000000..4bae8f1 --- /dev/null +++ b/tests/ha_stubs/homeassistant/helpers/update_coordinator.py @@ -0,0 +1,60 @@ +from typing import Generic, TypeVar + +from ..exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError + +_T = TypeVar("_T") + + +class UpdateFailed(HomeAssistantError): + pass + + +class DataUpdateCoordinator(Generic[_T]): + def __init__(self, hass, logger, *, config_entry=None, name=None, update_interval=None): + self.hass = hass + self.logger = logger + self.config_entry = config_entry + self.name = name + self.update_interval = update_interval + self.data = None + self.last_update_success = True + self.last_exception = None + self._listeners = [] + + def async_add_listener(self, listener): + self._listeners.append(listener) + return lambda: self._listeners.remove(listener) + + def _notify(self): + for listener in list(self._listeners): + listener() + + async def async_refresh(self): + try: + self.data = await self._async_update_data() + except UpdateFailed as err: + self.last_update_success = False + self.last_exception = err + else: + self.last_update_success = True + self._notify() + + async def async_config_entry_first_refresh(self): + try: + self.data = await self._async_update_data() + except UpdateFailed as err: + raise ConfigEntryNotReady(str(err)) from err + + def async_set_updated_data(self, data): + self.data = data + self.last_update_success = True + self._notify() + + +class CoordinatorEntity(Generic[_T]): + def __init__(self, coordinator): + self.coordinator = coordinator + + @property + def available(self): + return self.coordinator.last_update_success diff --git a/tests/ha_stubs/homeassistant/util/__init__.py b/tests/ha_stubs/homeassistant/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ha_stubs/homeassistant/util/dt.py b/tests/ha_stubs/homeassistant/util/dt.py new file mode 100644 index 0000000..2ba041e --- /dev/null +++ b/tests/ha_stubs/homeassistant/util/dt.py @@ -0,0 +1,21 @@ +from datetime import datetime, timezone + +DEFAULT_TIME_ZONE = timezone.utc + + +def utcnow(): + return datetime.now(timezone.utc) + + +def as_utc(value): + if value.tzinfo is None: + value = value.replace(tzinfo=DEFAULT_TIME_ZONE) + return value.astimezone(timezone.utc) + + +def as_local(value): + return value.astimezone(DEFAULT_TIME_ZONE) + + +def start_of_local_day(): + return utcnow().astimezone(DEFAULT_TIME_ZONE).replace(hour=0, minute=0, second=0, microsecond=0) diff --git a/tests/ha_stubs/homeassistant/util/unit_system.py b/tests/ha_stubs/homeassistant/util/unit_system.py new file mode 100644 index 0000000..b5bfdc2 --- /dev/null +++ b/tests/ha_stubs/homeassistant/util/unit_system.py @@ -0,0 +1,6 @@ +class UnitSystem: + pass + + +METRIC_SYSTEM = UnitSystem() +US_CUSTOMARY_SYSTEM = UnitSystem() diff --git a/tests/ha_stubs/voluptuous.py b/tests/ha_stubs/voluptuous.py new file mode 100644 index 0000000..b89362f --- /dev/null +++ b/tests/ha_stubs/voluptuous.py @@ -0,0 +1,101 @@ +"""Just enough of voluptuous for the integration's schemas.""" + + +class Invalid(Exception): + pass + + +class _Marker: + def __init__(self, key, default=None): + self.key = key + self.default = default + + def __hash__(self): + return hash(self.key) + + def __eq__(self, other): + return self.key == getattr(other, "key", other) + + +class Required(_Marker): + pass + + +class Optional(_Marker): + pass + + +def _check(validator, value): + if isinstance(validator, type): + if not isinstance(value, validator): + raise Invalid(f"expected {validator.__name__}") + return value + return validator(value) + + +class Schema: + def __init__(self, schema): + self.schema = schema + + def __call__(self, data): + result = {} + known = set() + for marker, validator in self.schema.items(): + known.add(marker.key) + if marker.key in data: + result[marker.key] = _check(validator, data[marker.key]) + elif isinstance(marker, Required): + raise Invalid(f"required key not provided: {marker.key}") + elif marker.default is not None: + result[marker.key] = marker.default + extra = set(data) - known + if extra: + raise Invalid(f"extra keys not allowed: {sorted(extra)}") + return result + + +class All: + def __init__(self, *validators): + self.validators = validators + + def __call__(self, value): + for validator in self.validators: + value = _check(validator, value) + return value + + +def In(options): + def validate(value): + if value not in options: + raise Invalid(f"{value} not in {options}") + return value + + return validate + + +def Coerce(kind): + def validate(value): + try: + return kind(value) + except (TypeError, ValueError) as err: + raise Invalid(str(err)) from err + + return validate + + +def Range(min=None): + def validate(value): + if min is not None and value < min: + raise Invalid(f"{value} below {min}") + return value + + return validate + + +def Length(min=None): + def validate(value): + if min is not None and len(value) < min: + raise Invalid("too short") + return value + + return validate diff --git a/tests/load_library.py b/tests/load_library.py new file mode 100644 index 0000000..428a902 --- /dev/null +++ b/tests/load_library.py @@ -0,0 +1,20 @@ +"""Makes ``import babytracker_api`` work without Home Assistant. + +The library is loaded by path because the integration folder holds a calendar.py, +which would hide Python's own calendar module if that folder were on sys.path. +""" + +import importlib.util +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +LIBRARY = ROOT / "custom_components" / "babytracker" / "babytracker_api" + +if "babytracker_api" not in sys.modules: + spec = importlib.util.spec_from_file_location( + "babytracker_api", LIBRARY / "__init__.py", submodule_search_locations=[str(LIBRARY)] + ) + module = importlib.util.module_from_spec(spec) + sys.modules["babytracker_api"] = module + spec.loader.exec_module(module) diff --git a/tests/mock_server.py b/tests/mock_server.py new file mode 100644 index 0000000..b26888c --- /dev/null +++ b/tests/mock_server.py @@ -0,0 +1,178 @@ +"""In-process fake of the sync server, written from the app's client code. + +It checks that this client speaks the protocol as the decompiled app does. It +cannot show that the real server behaves as the app expects. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Optional + +COOKIE = "session=abc123" + + +class MockState: + def __init__(self) -> None: + self.account_id = 42 + self.email = "parent@example.com" + self.password = "correct-horse" + self.valid_sessions = True + self.login_status: Optional[int] = None + self.devices: list[dict[str, Any]] = [] + self.logs: dict[str, list[dict[str, Any]]] = {} + self.uploads: list[dict[str, Any]] = [] + self.files: dict[str, bytes] = {} + self.seed: Optional[bytes] = None + self.requests: list[tuple[str, str]] = [] + self.login_bodies: list[dict[str, Any]] = [] + self.multipart: dict[str, tuple[str, bytes]] = {} + # Make uploads appear in the uploader's log, as a real server would. + self.log_uploads = False + self.fail_with: Optional[int] = None + + +class Handler(BaseHTTPRequestHandler): + server: "MockServer" + + def log_message(self, *args: Any) -> None: + pass + + def _reply(self, status: int, body: Any = None, headers: Optional[dict[str, str]] = None) -> None: + payload = b"" + if isinstance(body, bytes): + payload = body + elif body is not None: + payload = json.dumps(body).encode("utf-8") + self.send_response(status) + for key, value in (headers or {}).items(): + self.send_header(key, value) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _body(self) -> bytes: + return self.rfile.read(int(self.headers.get("Content-Length") or 0)) + + def _authorized(self) -> bool: + state = self.server.state + if state.valid_sessions and COOKIE in (self.headers.get("Cookie") or ""): + return True + self._reply(401, b"unauthorized") + return False + + def _login(self, body: bytes) -> None: + state = self.server.state + data = json.loads(body) + state.login_bodies.append(data) + if state.login_status: + self._reply(state.login_status, b"nope", {"Location": "/redirected"}) + return + if data["EmailAddress"] != state.email or data["Password"] != state.password: + self._reply(401, b"bad credentials") + return + state.valid_sessions = True + self._reply(200, {"AccountID": state.account_id}, {"Set-Cookie": f"{COOKIE}; Path=/"}) + + def do_POST(self) -> None: + state = self.server.state + state.requests.append(("POST", self.path)) + body = self._body() + if state.fail_with and self.path != "/session": + self._reply(state.fail_with) + return + if self.path == "/session": + self._login(body) + elif self.path == "/redirected": + self._reply(200, {"AccountID": 666}) + elif self.path == "/account/forgot_password": + self._reply(200) + elif self.path in ("/staging_account", "/account_reset"): + state.login_bodies.append(json.loads(body)) + if json.loads(body)["EmailAddress"] == "taken@example.com": + self._reply(409, b"exists") + else: + self._reply(200, None, {"Set-Cookie": f"{COOKIE}; Path=/"}) + elif not self._authorized(): + return + elif self.path == "/device_token": + state.multipart["token"] = ("", body) + self._reply(201) + elif self.path == "/account/transaction": + data = json.loads(body) + state.uploads.append(data) + if state.log_uploads: + uuid = state.login_bodies[-1]["Device"]["DeviceUUID"] + state.logs.setdefault(uuid, []).append(data) + state.devices = [d for d in state.devices if d["DeviceUUID"] != uuid] + state.devices.append({"DeviceUUID": uuid, "LastSyncID": data["SyncID"]}) + self._reply(201) + elif self.path in ("/account/picture", "/staging_account/picture", "/staging_account/seed", "/account/clonedata"): + state.multipart[self.path] = (self.headers.get("Content-Type", ""), body) + self._reply(201) + else: + self._reply(404) + + def do_PUT(self) -> None: + state = self.server.state + state.requests.append(("PUT", self.path)) + self._body() + if self.path != "/staging_account": + self._reply(404) + elif "/staging_account/seed" not in state.multipart: + self._reply(424, b"no seed") + elif self._authorized(): + self._reply(200, {"AccountID": state.account_id}) + + def do_DELETE(self) -> None: + self.server.state.requests.append(("DELETE", self.path)) + if self._authorized(): + self._reply(200) + + def do_GET(self) -> None: + state = self.server.state + state.requests.append(("GET", self.path)) + if state.fail_with: + self._reply(state.fail_with) + return + if not self._authorized(): + return + parts = self.path.strip("/").split("/") + if self.path == "/account/device": + self._reply(200, state.devices) + elif parts[:2] == ["account", "transaction"] and len(parts) == 4: + log = state.logs.get(parts[2], []) + after = int(parts[3]) + self._reply(200, [item for item in log if item["SyncID"] > after][:500]) + elif self.path == "/account/seed": + if state.seed is None: + self._reply(404, b"no seed") + else: + self._reply(200, state.seed, {"Content-Disposition": "attachment; filename=seed.zip"}) + elif parts[:2] == ["account", "picture"] and parts[2] in state.files: + self._reply(200, state.files[parts[2]], {"Content-Disposition": f"attachment; filename={parts[2]}"}) + else: + self._reply(404) + + +class MockServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self) -> None: + super().__init__(("127.0.0.1", 0), Handler) + self.state = MockState() + self._thread = threading.Thread(target=self.serve_forever, args=(0.01,), daemon=True) + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self.server_address[1]}" + + def __enter__(self) -> "MockServer": + self._thread.start() + return self + + def __exit__(self, *args: Any) -> None: + self.shutdown() + self.server_close() diff --git a/tests/seed_fixture.py b/tests/seed_fixture.py new file mode 100644 index 0000000..07b640b --- /dev/null +++ b/tests/seed_fixture.py @@ -0,0 +1,81 @@ +"""Builds a seed database using table definitions taken from the app (4.53).""" + +import sqlite3 +import zipfile +from pathlib import Path + +_ID = '"ID" TEXT PRIMARY KEY NOT NULL UNIQUE, "Timestamp" REAL DEFAULT 0' +_ACTIVITY = f'{_ID}, "Time" REAL NOT NULL, "Note" TEXT, "HasPicture" INTEGER' + +SCHEMA = f""" +CREATE TABLE "Baby" ({_ID}, "Name" TEXT NOT NULL UNIQUE, "DOB" REAL, "DueDay" REAL, "Gender" INTEGER, "Picture" TEXT); +CREATE TABLE "MergedTransaction" ("DeviceID" TEXT PRIMARY KEY NOT NULL UNIQUE, "SyncID" INTEGER); +CREATE TABLE "Diaper" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Status" INTEGER NOT NULL, "PeeColor" INTEGER DEFAULT 5, + "PooColor" INTEGER DEFAULT 5, "Amount" INTEGER DEFAULT 5, "Texture" INTEGER DEFAULT 5, "Flag" INTEGER DEFAULT 0); +CREATE TABLE "Sleep" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Duration" INTEGER DEFAULT 0, "LocationID" TEXT, "DescID" TEXT); +CREATE TABLE "Formula" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "DescID" TEXT, "Amount" REAL NOT NULL, "IsEnglishScale" INTEGER NOT NULL); +CREATE TABLE "Pumped" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "DescID" TEXT, "Amount" REAL NOT NULL, "IsEnglishScale" INTEGER NOT NULL, "PumpID" TEXT); +CREATE TABLE "Nursing" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "DescID" TEXT, "FinishSide" INTEGER, "LeftDuration" INTEGER DEFAULT 0, + "RightDuration" INTEGER DEFAULT 0, "BothDuration" INTEGER DEFAULT 0); +CREATE TABLE "Temperature" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Temp" REAL NOT NULL, "IsEnglishScale" INTEGER NOT NULL); +CREATE TABLE "Growth" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Weight" REAL DEFAULT 0, "Length" REAL DEFAULT 0, "Head" READ DEFAULT 0, + "IsEnglishLengthScale" INTEGER DEFAULT 1, "IsEnglishWeightScale" INTEGER DEFAULT 1); +CREATE TABLE "Medicine" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "MedID" TEXT NOT NULL, "Amount" REAL); +CREATE TABLE "MedicineSelection" ({_ID}, "Name" TEXT NOT NULL UNIQUE, "Description" TEXT, "IsPrescription" INTEGER, + "AmountPerTime" REAL, "Unit" TEXT, "Interval" INTEGER); +CREATE TABLE "Pump" ({_ACTIVITY}, "FinishSide" INTEGER, "Sides" INTEGER, "Amount" REAL, "IsEnglishScale" INTEGER, "Label" TEXT, "PumpedID" TEXT); +CREATE TABLE "Sick" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Symptom" TEXT); +""" + +BABY_ID = "baby-1" + + +def build_seed_db(path: Path, now: float) -> Path: + """Write a database with one baby, Sam, whose entries are relative to ``now``.""" + connection = sqlite3.connect(path) + connection.executescript(SCHEMA) + connection.execute( + 'INSERT INTO "Baby" VALUES (?, ?, ?, ?, ?, ?, ?)', (BABY_ID, now - 86400, "Sam", now - 90 * 86400, 0, 1, None) + ) + connection.execute('INSERT INTO "MergedTransaction" VALUES (?, ?)', ("phone", 3)) + hour = 3600 + rows = { + '"Diaper" ("ID","Timestamp","Time","Note","BabyID","Status","Amount")': [ + ("d-old", now, now - 40 * hour, "", BABY_ID, 0, 2), + ("d-dry", now, now - 2 * hour, "", BABY_ID, 0, 0), + ("d-new", now, now - 60, "messy", BABY_ID, 1, 3), + ], + '"Sleep" ("ID","Timestamp","Time","BabyID","Duration")': [("s-1", now, now - 3 * hour, BABY_ID, 45)], + '"Formula" ("ID","Timestamp","Time","BabyID","Amount","IsEnglishScale")': [ + ("f-oz", now, now - 120, BABY_ID, 4, 1), + ("f-ml", now, now - 180, BABY_ID, 100, 0), + ], + '"Nursing" ("ID","Timestamp","Time","BabyID","FinishSide","LeftDuration","RightDuration")': [ + ("n-1", now, now - 5 * hour, BABY_ID, 2, 5, 7) + ], + '"Temperature" ("ID","Timestamp","Time","BabyID","Temp","IsEnglishScale")': [ + ("t-1", now, now - 6 * hour, BABY_ID, 98.6, 1) + ], + '"Growth" ("ID","Timestamp","Time","BabyID","Weight","Length","Head","IsEnglishLengthScale","IsEnglishWeightScale")': [ + ("g-old", now, now - 50 * hour, BABY_ID, 5.5, 60, 40, 0, 0), + ("g-new", now, now - 7 * hour, BABY_ID, 13.2277, 0, 0, 1, 1), + ], + '"MedicineSelection" ("ID","Timestamp","Name")': [("med-1", now, "Vitamin D")], + '"Medicine" ("ID","Timestamp","Time","BabyID","MedID","Amount")': [("m-1", now, now - 8 * hour, BABY_ID, "med-1", 1)], + '"Pump" ("ID","Timestamp","Time","Amount","IsEnglishScale")': [("p-1", now, now - 9 * hour, 80, 0)], + '"Sick" ("ID","Timestamp","Time","BabyID","Symptom")': [("k-1", now, now - 10 * hour, BABY_ID, "cough")], + } + for target, values in rows.items(): + marks = ",".join("?" * len(values[0])) + connection.executemany(f"INSERT INTO {target} VALUES ({marks})", values) + connection.commit() + connection.close() + return path + + +def build_seed_zip(folder: Path, now: float) -> bytes: + db = build_seed_db(folder / "fixture.db", now) + zip_path = folder / "fixture.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.write(db, "EasyLog.db") + return zip_path.read_bytes() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..49f2788 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,392 @@ +import base64 +import json +import sqlite3 +import sys +import tempfile +import unittest +import zipfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import load_library # noqa: F401 + +from mock_server import MockServer + +from babytracker_api import ( + AccountChangedError, + AccountStateError, + AuthenticationError, + BabyTrackerClient, + ConflictError, + DiaperAmount, + DiaperStatus, + MissingSeedError, + NotFoundError, + NursingFinishSide, + OpCode, + Transaction, + decode_object, + diaper, + encode_object, + extract_seed_db, + format_date, + formula, + growth, + new_activity, + nursing, + open_seed_db, + parse_date, + read_babies, + read_cursors, + sleep, +) + +BABY = {"objectID": "baby-1", "name": "Sam", "gender": True, "newFlage": False} + + +def log_entry(sync_id, op="0"): + payload = encode_object({"BCObjectType": "Bath", "objectID": f"o{sync_id}"}) + return {"SyncID": sync_id, "OPCode": op, "Transaction": payload} + + +class ServerTestCase(unittest.TestCase): + def setUp(self): + self.server = MockServer() + self.server.__enter__() + self.addCleanup(self.server.__exit__) + self.state = self.server.state + self.client = BabyTrackerClient( + self.state.email, self.state.password, device_uuid="me", device_name="Test", base_url=self.server.url + ) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + + +class SessionTests(ServerTestCase): + def test_login_sends_the_apps_body_and_returns_account_id(self): + self.assertEqual(self.client.login(), 42) + self.assertEqual( + self.state.login_bodies[0], + { + "AppInfo": {"AccountType": 0, "AppType": 0}, + "Device": {"DeviceName": "Test", "DeviceOSInfo": "Python", "DeviceUUID": "me"}, + "EmailAddress": "parent@example.com", + "Password": "correct-horse", + }, + ) + + def test_bad_password_raises(self): + self.client.password = "wrong" + with self.assertRaises(AuthenticationError) as caught: + self.client.login() + self.assertEqual(caught.exception.status, 401) + + def test_login_307_is_not_followed(self): + self.state.login_status = 307 + with self.assertRaises(AccountStateError): + self.client.login() + self.assertNotIn(("POST", "/redirected"), self.state.requests) + + def test_expired_session_logs_in_again_and_retries(self): + self.client.login() + self.state.valid_sessions = False + self.assertEqual(self.client.get_devices(), []) + self.assertEqual(len(self.state.login_bodies), 2) + + def test_first_request_without_login_works(self): + self.assertEqual(self.client.get_devices(), []) + + def test_account_change_on_relogin_raises(self): + self.client.login() + self.state.valid_sessions = False + self.state.account_id = 99 + with self.assertRaises(AccountChangedError): + self.client.get_devices() + + def test_logout_and_push_token(self): + self.client.login() + self.client.register_push_token("tok") + self.assertEqual(json.loads(self.state.multipart["token"][1]), {"TokenNative": "tok", "TokenAppType": 5}) + self.client.logout() + self.assertIn(("DELETE", "/session"), self.state.requests) + + +class PullTests(ServerTestCase): + def test_pages_until_a_short_page(self): + self.state.logs["other"] = [log_entry(i) for i in range(1, 1203)] + self.client.login() + items = list(self.client.iter_transactions("other")) + self.assertEqual([item.sync_id for item in items], list(range(1, 1203))) + paths = [path for _, path in self.state.requests if "transaction" in path] + self.assertEqual( + paths, + ["/account/transaction/other/0", "/account/transaction/other/500", "/account/transaction/other/1000"], + ) + + def test_exact_page_boundary_fetches_one_empty_page(self): + self.state.logs["other"] = [log_entry(i) for i in range(1, 501)] + self.assertEqual(len(list(self.client.iter_transactions("other"))), 500) + + def test_server_ignoring_cursor_does_not_loop_or_repeat(self): + real = self.client.get_transactions + full_page = [Transaction.from_json(log_entry(i)) for i in range(1, 501)] + self.client.get_transactions = lambda device, after=0: full_page + self.addCleanup(setattr, self.client, "get_transactions", real) + self.assertEqual(len(list(self.client.iter_transactions("other"))), 500) + + def test_pull_skips_own_device_and_seen_items_and_tracks_cursors(self): + self.state.devices = [ + {"DeviceUUID": "me", "LastSyncID": 5}, + {"DeviceUUID": "a", "LastSyncID": 3}, + {"DeviceUUID": "b", "LastSyncID": 2}, + ] + self.state.logs = { + "me": [log_entry(5)], + "a": [log_entry(1), log_entry(2), log_entry(3)], + "b": [log_entry(1), log_entry(2)], + } + cursors = {"a": 1, "b": 2} + pulled = [(device, item.sync_id) for device, item in self.client.pull(cursors)] + self.assertEqual(pulled, [("a", 2), ("a", 3)]) + self.assertEqual(cursors, {"a": 3, "b": 2}) + self.assertEqual(list(self.client.pull(cursors)), []) + + def test_opcode_accepts_numbers_as_well_as_strings(self): + self.state.logs["a"] = [log_entry(1, op=2), log_entry(2, op="1")] + items = self.client.get_transactions("a") + self.assertEqual([item.op for item in items], [OpCode.DELETE, OpCode.UPDATE]) + self.assertEqual(items[0].object_type, "Bath") + + +class PushTests(ServerTestCase): + def test_add_uses_gson_wire_format(self): + when = datetime(2026, 11, 4, 8, 30, tzinfo=timezone.utc) + record = diaper(BABY, DiaperStatus.POOPY, DiaperAmount.HEAVY, time=when, note="x") + self.client.add(record) + + upload = self.state.uploads[0] + self.assertEqual(set(upload), {"SyncID", "OPCode", "Transaction"}) + self.assertEqual(upload["SyncID"], 1) + self.assertEqual(upload["OPCode"], "0") + + body = json.loads(base64.b64decode(upload["Transaction"])) + self.assertEqual(body["BCObjectType"], "Diaper") + self.assertEqual(body["status"], "1") + self.assertEqual(body["amount"], "3") + self.assertEqual(body["time"], "2026-11-04 08:30:00 +0000") + self.assertEqual(body["baby"], BABY) + self.assertNotIn("BCObjectType", body["baby"]) + self.assertIs(body["pictureLoaded"], True) + self.assertEqual(body["pictureNote"], []) + + def test_sync_ids_resume_from_server_then_count(self): + self.state.devices = [{"DeviceUUID": "me", "LastSyncID": 7}, {"DeviceUUID": "x", "LastSyncID": 100}] + self.client.login() + for _ in range(3): + self.client.add(sleep(BABY, 30)) + self.assertEqual([u["SyncID"] for u in self.state.uploads], [8, 9, 10]) + device_lists = [r for r in self.state.requests if r == ("GET", "/account/device")] + self.assertEqual(len(device_lists), 1) + + def test_failed_upload_does_not_consume_a_sync_id(self): + self.client.login() + record = sleep(BABY, 30) + original = self.client.upload_transaction + self.client.upload_transaction = lambda item: (_ for _ in ()).throw(ConflictError(409)) + with self.assertRaises(ConflictError): + self.client.add(record) + self.client.upload_transaction = original + self.client.add(record) + self.assertEqual(self.state.uploads[0]["SyncID"], 1) + + def test_update_and_delete_send_whole_record_with_new_timestamp(self): + old = datetime(2020, 1, 1, tzinfo=timezone.utc) + record = {**sleep(BABY, 30), "timestamp": old} + self.client.update(record) + self.client.delete(record) + self.assertEqual([u["OPCode"] for u in self.state.uploads], ["1", "2"]) + for upload in self.state.uploads: + body = json.loads(base64.b64decode(upload["Transaction"])) + self.assertEqual(body["objectID"], record["objectID"]) + self.assertGreater(parse_date(body["timestamp"]), old) + self.assertEqual(record["timestamp"], old) + + +class FileTests(ServerTestCase): + def _make_seed(self): + db_path = self.tmp / "source.db" + connection = sqlite3.connect(db_path) + connection.execute( + "CREATE TABLE Baby (ID TEXT, Timestamp REAL, Name TEXT, DOB REAL, DueDay REAL, Gender INT, Picture TEXT)" + ) + connection.execute('CREATE TABLE MergedTransaction ("DeviceID" TEXT, "SyncID" INTEGER)') + connection.execute("INSERT INTO Baby VALUES ('b1', 1700000000.5, 'Sam', 1600000000, 0, 1, NULL)") + connection.execute("INSERT INTO MergedTransaction VALUES ('a', 9)") + connection.commit() + connection.close() + zip_path = self.tmp / "made.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.write(db_path, "EasyLog.db") + archive.writestr("../../evil.txt", "ignored") + return zip_path.read_bytes() + + def test_seed_download_and_read(self): + self.state.seed = self._make_seed() + seed_zip = self.client.download_seed(self.tmp / "seed.zip") + connection = open_seed_db(extract_seed_db(seed_zip, self.tmp / "seed.db")) + self.addCleanup(connection.close) + + babies = read_babies(connection) + self.assertEqual(babies[0]["objectID"], "b1") + self.assertIs(babies[0]["gender"], True) + self.assertEqual(babies[0]["dob"], "2020-09-13 12:26:40 +0000") + self.assertNotIn("dueDay", babies[0]) + self.assertNotIn("pictureName", babies[0]) + self.assertEqual(read_cursors(connection), {"a": 9}) + with self.assertRaises(sqlite3.OperationalError): + connection.execute("DELETE FROM Baby") + self.assertFalse((self.tmp.parent / "evil.txt").exists()) + + self.client.add(diaper(babies[0], DiaperStatus.WET)) + body = decode_object(self.state.uploads[0]["Transaction"]) + self.assertEqual(body["baby"]["objectID"], "b1") + + def test_missing_seed_raises(self): + with self.assertRaises(NotFoundError): + self.client.download_seed(self.tmp / "seed.zip") + + def test_seed_zip_without_database_raises(self): + path = self.tmp / "empty.zip" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("other.txt", "x") + with self.assertRaises(ValueError): + extract_seed_db(path, self.tmp / "seed.db") + + def test_picture_upload_matches_the_apps_bytes(self): + path = self.tmp / "abc.jpg" + path.write_bytes(b"\xff\xd8jpeg") + self.client.login() + self.client.upload_picture(path) + content_type, body = self.state.multipart["/account/picture"] + self.assertEqual(content_type, "multipart/form-data; boundary=__BABY_TRACKER_BOUNDARY____Photo__") + self.assertEqual( + body, + b"\r\n--__BABY_TRACKER_BOUNDARY____Photo__\r\n" + b'Content-Disposition: form-data; name="picture"; filename="abc.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"\xff\xd8jpeg" + b"\r\n--__BABY_TRACKER_BOUNDARY____Photo__--\r\n", + ) + + def test_picture_download_quotes_the_name(self): + self.state.files["abc__thumbnail.jpg"] = b"data" + self.client.login() + path = self.client.download_picture("abc__thumbnail.jpg", self.tmp / "pic.jpg") + self.assertEqual(path.read_bytes(), b"data") + with self.assertRaises(NotFoundError): + self.client.download_picture("../session", self.tmp / "x.jpg") + self.assertIn(("GET", "/account/picture/..%2Fsession"), self.state.requests) + + +class GroupTests(ServerTestCase): + def setUp(self): + super().setUp() + self.seed = self.tmp / "local.zip" + self.seed.write_bytes(b"zipdata") + self.picture = self.tmp / "p.jpg" + self.picture.write_bytes(b"jpg") + + def test_create_group_runs_the_apps_steps_in_order(self): + self.assertEqual(self.client.create_group(self.seed, [self.picture]), 42) + self.assertEqual( + self.state.requests, + [ + ("POST", "/staging_account"), + ("POST", "/staging_account/seed"), + ("POST", "/staging_account/picture"), + ("PUT", "/staging_account"), + ], + ) + content_type, body = self.state.multipart["/staging_account/seed"] + self.assertEqual(content_type, "multipart/form-data; boundary=__BABY_TRACKER_BOUNDARY____SEED__") + self.assertIn(b'name="seed"; filename="seed.zip"\r\nContent-Type: application/zip\r\n\r\nzipdata', body) + + def test_create_group_for_taken_email_raises(self): + self.client.email = "taken@example.com" + with self.assertRaises(ConflictError): + self.client.create_group(self.seed) + + def test_reset_requires_confirmation(self): + with self.assertRaises(ValueError): + self.client.reset_group(self.seed) + self.assertEqual(self.state.requests, []) + + def test_reset_with_confirmation(self): + self.assertEqual(self.client.reset_group(self.seed, confirm=True), 42) + self.assertEqual(self.state.requests[0], ("POST", "/account_reset")) + + def test_finalizing_without_seed_raises(self): + self.client.login() + with self.assertRaises(MissingSeedError): + self.client._check(self.client._send("PUT", "/staging_account"), 200) + + +class ModelTests(unittest.TestCase): + def test_date_round_trip_converts_to_gmt(self): + tz = timezone(timedelta(hours=2)) + text = format_date(datetime(2026, 11, 4, 10, 0, 5, tzinfo=tz)) + self.assertEqual(text, "2026-11-04 08:00:05 +0000") + self.assertEqual(parse_date(text), datetime(2026, 11, 4, 8, 0, 5, tzinfo=timezone.utc)) + + def test_naive_datetime_is_rejected(self): + with self.assertRaises(ValueError): + format_date(datetime(2026, 1, 1)) + + def test_parses_the_apps_localized_fallback_dates(self): + expected = datetime(2026, 11, 4, 15, 0, 5, tzinfo=timezone.utc) + for text in ( + "2026-11-04 03:00:05 PM +0000", + "2026-11-04 03:00:05 p.m. +0000", + "2026-11-04 03:00:05 nachm. +0000", + "2026-11-04 03:00:05 午後 +0000", + "2026-11-04 03:00:05 下午 +0000", + "2026-11-04 03:00:05 오후 +0000", + "2026-11-04 03:00:05 em +0000", + ): + self.assertEqual(parse_date(text), expected, text) + + def test_unknown_fields_survive_a_round_trip(self): + record = {"BCObjectType": "FutureType", "newThing": {"a": [1, "é"]}} + self.assertEqual(decode_object(encode_object(record)), record) + + def test_record_needs_a_type(self): + with self.assertRaises(ValueError): + encode_object({"objectID": "x"}) + + def test_enums_become_strings_even_when_nested(self): + record = new_activity("Pump", None, extra=[{"sides": DiaperStatus.MIXED}]) + body = decode_object(encode_object(record)) + self.assertEqual(body["extra"], [{"sides": "2"}]) + self.assertNotIn("baby", body) + + def test_builders(self): + body = decode_object(encode_object(formula(BABY, 4, english=True))) + self.assertEqual(body["amount"], {"englishMeasure": True, "value": 4.0}) + body = decode_object(encode_object(nursing(BABY, 5, 7, finish_side=NursingFinishSide.RIGHT))) + self.assertEqual((body["leftDuration"], body["rightDuration"], body["finishSide"]), (5, 7, "2")) + body = decode_object(encode_object(growth(BABY, weight=3.5))) + self.assertEqual(body["weight"], {"englishMeasure": False, "value": 3.5}) + self.assertNotIn("length", body) + self.assertEqual(decode_object(encode_object(diaper(BABY, DiaperStatus.WET)))["amount"], "5") + + def test_builder_time_and_note(self): + when = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + body = decode_object(encode_object(sleep(BABY, 90, time=when, note="nap"))) + self.assertEqual((body["time"], body["note"], body["duration"]), ("2026-01-02 03:04:05 +0000", "nap", 90)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..b432201 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,633 @@ +"""Runs the integration's own code against stand-ins for Home Assistant's APIs. + +See tests/ha_stubs/README.md for what this can and cannot show. The client is +always pointed at the local mock server, so these tests never reach the real one. +""" + +import base64 +import json +import sys +import tempfile +import time +import unittest +from datetime import datetime, timedelta, timezone +from functools import partial +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tests")) +sys.path.insert(0, str(ROOT / "tests" / "ha_stubs")) +sys.path.insert(0, str(ROOT)) + +from mock_server import MockServer +from seed_fixture import BABY_ID, build_seed_zip + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM + +import custom_components.babytracker as integration +from custom_components.babytracker import calendar as calendar_platform +from custom_components.babytracker import config_flow, sensor as sensor_platform +from custom_components.babytracker.babytracker_api import BabyTrackerClient, encode_object +from custom_components.babytracker.const import CONF_DEVICE_UUID, DOMAIN + +DEVICE_UUID = "ha-device" + + +class _ConfigEntries: + def __init__(self): + self.entries = [] + + def async_entries(self, domain): + return self.entries + + async def async_forward_entry_setups(self, entry, platforms): + pass + + async def async_unload_platforms(self, entry, platforms): + return True + + +class IntegrationTestCase(unittest.IsolatedAsyncioTestCase): + units = METRIC_SYSTEM + + async def asyncSetUp(self): + self.server = MockServer() + self.server.__enter__() + self.addCleanup(self.server.__exit__) + self.state = self.server.state + self.state.log_uploads = True + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + self.now = time.time() + self.state.seed = build_seed_zip(Path(self.folder.name), self.now) + self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 3}] + + client = partial(BabyTrackerClient, base_url=self.server.url) + for target in (integration, config_flow): + patcher = mock.patch.object(target, "BabyTrackerClient", client) + patcher.start() + self.addCleanup(patcher.stop) + dr.REGISTRY.devices.clear() + + self.hass = HomeAssistant(self.units) + self.hass.config_entries = _ConfigEntries() + await integration.async_setup(self.hass, {}) + + def make_entry(self): + return ConfigEntry( + {"email": self.state.email, "password": self.state.password, CONF_DEVICE_UUID: DEVICE_UUID}, + str(self.state.account_id), + ) + + async def load(self): + entry = self.make_entry() + await integration.async_setup_entry(self.hass, entry) + entry.state = ConfigEntryState.LOADED + self.hass.config_entries.entries = [entry] + self.entry = entry + self.coordinator = entry.runtime_data + self.sensors = {} + self.calendars = [] + + def add_sensors(entities): + for entity in entities: + self.sensors[entity.unique_id if hasattr(entity, "unique_id") else entity._attr_unique_id] = entity + + await sensor_platform.async_setup_entry(self.hass, entry, add_sensors) + await calendar_platform.async_setup_entry(self.hass, entry, self.calendars.extend) + return entry + + def value(self, key, baby_id=BABY_ID): + return self.sensors[f"{baby_id}_{key}"].native_value + + async def call(self, service, data=None, return_response=False): + return await self.hass.services.async_call(DOMAIN, service, data, return_response=return_response) + + def last_upload(self): + upload = self.state.uploads[-1] + return upload, json.loads(base64.b64decode(upload["Transaction"])) + + def remote_entry(self, sync_id, record): + return {"SyncID": sync_id, "OPCode": "0", "Transaction": encode_object(record)} + + +class SetupTests(IntegrationTestCase): + async def test_loads_seed_into_sensors(self): + await self.load() + last = self.value("last_diaper") + self.assertEqual(last, datetime.fromtimestamp(self.now - 60, timezone.utc)) + attributes = self.sensors[f"{BABY_ID}_last_diaper"].extra_state_attributes + self.assertEqual((attributes["status"], attributes["amount"], attributes["note"]), ("poopy", "heavy", "messy")) + self.assertAlmostEqual(self.value("temperature"), 37.0, places=3) + self.assertAlmostEqual(self.value("weight"), 6.0, places=3) + self.assertEqual(self.value("length"), 60) + self.assertIn(("POST", "/session"), self.state.requests) + + async def test_entities_have_unique_ids_and_a_device_per_baby(self): + await self.load() + self.assertEqual(len(self.sensors), len(sensor_platform.SENSORS) + 1) + sensor = self.sensors[f"{BABY_ID}_weight"] + self.assertEqual(sensor._attr_device_info["identifiers"], {(DOMAIN, BABY_ID)}) + self.assertEqual(sensor._attr_device_info["name"], "Sam") + self.assertEqual(self.calendars[0]._attr_translation_key, "log") + + async def test_bad_password_starts_reauth(self): + entry = self.make_entry() + self.state.password = "changed" + with self.assertRaises(ConfigEntryAuthFailed): + await integration.async_setup_entry(self.hass, entry) + + async def test_server_down_retries_setup(self): + self.state.fail_with = 500 + with self.assertRaises(ConfigEntryNotReady): + await integration.async_setup_entry(self.hass, self.make_entry()) + + async def test_account_without_seed_still_loads(self): + self.state.seed = None + self.state.devices = [] + await self.load() + self.assertEqual(self.coordinator.data.babies, {}) + + async def test_seed_files_are_not_left_behind(self): + import glob + import tempfile as tf + + await self.load() + self.assertEqual(glob.glob(f"{tf.gettempdir()}/babytracker*"), []) + + +class SyncTests(IntegrationTestCase): + async def test_replays_entries_newer_than_the_seed_and_skips_older(self): + record = { + "BCObjectType": "Sleep", "objectID": "s-remote", "baby": {"objectID": BABY_ID}, + "time": "2030-01-01 00:00:00 +0000", "duration": 10, + } + self.state.logs["phone"] = [ + self.remote_entry(3, {**record, "objectID": "already-in-seed"}), + self.remote_entry(4, record), + ] + self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}] + await self.load() + ids = {entry.id for entry in self.coordinator.data.entries(BABY_ID)} + self.assertIn("s-remote", ids) + self.assertNotIn("already-in-seed", ids) + + async def test_refresh_fetches_new_entries_only(self): + await self.load() + self.state.requests.clear() + self.state.logs["phone"] = [ + self.remote_entry(4, { + "BCObjectType": "Diaper", "objectID": "d-remote", "baby": {"objectID": BABY_ID}, + "time": "2030-01-01 00:00:00 +0000", "status": "0", "amount": "2", + }) + ] + self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}] + await self.coordinator.async_refresh() + self.assertEqual(self.coordinator.data.latest(BABY_ID).id, "d-remote") + self.assertIn(("GET", "/account/transaction/phone/3"), self.state.requests) + self.assertNotIn(("GET", "/account/seed"), self.state.requests) + self.state.requests.clear() + await self.coordinator.async_refresh() + self.assertFalse([r for r in self.state.requests if "transaction" in r[1]]) + + async def test_failed_refresh_loses_nothing_and_recovers(self): + await self.load() + self.state.logs["phone"] = [self.remote_entry(4, { + "BCObjectType": "Bath", "objectID": "b-remote", "baby": {"objectID": BABY_ID}, + "time": "2030-01-01 00:00:00 +0000", + })] + self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}] + self.state.fail_with = 503 + await self.coordinator.async_refresh() + self.assertFalse(self.coordinator.last_update_success) + self.assertFalse(self.sensors[f"{BABY_ID}_weight"].available) + + self.state.fail_with = None + await self.coordinator.async_refresh() + self.assertTrue(self.coordinator.last_update_success) + self.assertEqual(self.coordinator.data.latest(BABY_ID).id, "b-remote") + + async def test_unreadable_entry_is_skipped(self): + await self.load() + self.state.logs["phone"] = [ + {"SyncID": 4, "OPCode": "0", "Transaction": "not base64 json!"}, + self.remote_entry(5, {"BCObjectType": "Bath", "objectID": "good", "baby": {"objectID": BABY_ID}, + "time": "2030-01-01 00:00:00 +0000"}), + self.remote_entry(6, {"BCObjectType": "Bath", "objectID": "bad-date", "baby": {"objectID": BABY_ID}, + "time": "whenever"}), + ] + self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 6}] + with self.assertLogs("custom_components.babytracker.coordinator", "WARNING"): + await self.coordinator.async_refresh() + self.assertTrue(self.coordinator.last_update_success) + self.assertEqual(self.coordinator.data.latest(BABY_ID).id, "good") + + async def test_new_baby_from_the_log_gets_entities(self): + await self.load() + self.state.logs["phone"] = [self.remote_entry(4, {"BCObjectType": "Baby", "objectID": "b2", "name": "Alex"})] + self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}] + await self.coordinator.async_refresh() + self.assertIn("b2_last_diaper", self.sensors) + self.assertEqual(len(self.calendars), 2) + + async def test_deleted_baby_becomes_unavailable(self): + await self.load() + self.state.logs["phone"] = [{ + "SyncID": 4, "OPCode": "2", "Transaction": encode_object({"BCObjectType": "Baby", "objectID": BABY_ID}) + }] + self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}] + await self.coordinator.async_refresh() + self.assertFalse(self.sensors[f"{BABY_ID}_weight"].available) + + async def test_expired_password_on_refresh_starts_reauth(self): + await self.load() + self.state.valid_sessions = False + self.state.password = "changed" + with self.assertRaises(ConfigEntryAuthFailed): + await self.coordinator.async_refresh() + + async def test_different_account_is_refused(self): + self.state.account_id = 777 + with self.assertRaises(ConfigEntryAuthFailed): + await integration.async_setup_entry(self.hass, ConfigEntry( + {"email": self.state.email, "password": self.state.password, CONF_DEVICE_UUID: DEVICE_UUID}, "42" + )) + + async def test_restart_keeps_what_this_device_uploaded(self): + await self.load() + await self.call("log_bath", {"note": "first"}) + await self.call("log_bath", {"note": "second"}) + self.assertEqual([u["SyncID"] for u in self.state.uploads], [1, 2]) + + await self.load() # A new client and replica, as after a restart. + notes = [e.note for e in self.coordinator.data.entries(BABY_ID, {"Bath"})] + self.assertCountEqual(notes, ["first", "second"]) + await self.call("log_bath", {"note": "third"}) + self.assertEqual(self.state.uploads[-1]["SyncID"], 3) + + +class ServiceTests(IntegrationTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + await self.load() + + async def test_every_service_is_registered_with_optional_response(self): + names = {name for _, name in self.hass.services.registered} + self.assertEqual(names, { + "log_diaper", "log_sleep", "log_bottle", "log_nursing", "log_temperature", + "log_growth", "log_bath", "log_joy", "log_journal", + }) + + async def test_diaper_uploads_and_updates_sensors_at_once(self): + before = self.value("diapers_today") + response = await self.call("log_diaper", {"status": "mixed", "amount": "light", "note": "n"}, return_response=True) + upload, body = self.last_upload() + self.assertEqual(upload["OPCode"], "0") + self.assertEqual((body["BCObjectType"], body["status"], body["amount"], body["note"]), ("Diaper", "2", "1", "n")) + self.assertEqual(body["baby"]["objectID"], BABY_ID) + self.assertEqual(body["baby"]["name"], "Sam") + self.assertNotIn("BCObjectType", body["baby"]) + self.assertEqual(response, {"id": body["objectID"]}) + self.assertEqual(self.value("diapers_today"), before + 1) + self.assertEqual(self.sensors[f"{BABY_ID}_last_diaper"].extra_state_attributes["status"], "mixed") + + async def test_no_response_unless_asked(self): + self.assertIsNone(await self.call("log_diaper", {"status": "wet"})) + + async def test_diaper_without_amount_is_not_counted_dry(self): + await self.call("log_diaper", {"status": "wet"}) + _, body = self.last_upload() + self.assertEqual(body["amount"], "5") + self.assertEqual(self.sensors[f"{BABY_ID}_last_diaper"].extra_state_attributes["status"], "wet") + + async def test_sleep_defaults_to_having_just_finished(self): + before = self.value("sleep_today") + await self.call("log_sleep", {"duration": 30}) + _, body = self.last_upload() + started = datetime.strptime(body["time"], "%Y-%m-%d %H:%M:%S %z") + self.assertAlmostEqual((datetime.now(timezone.utc) - started).total_seconds(), 1800, delta=5) + self.assertEqual(body["duration"], 30) + if started.date() == datetime.now(timezone.utc).date(): + self.assertEqual(self.value("sleep_today"), before + 30) + + async def test_explicit_time_is_read_as_local_and_sent_as_gmt(self): + await self.call("log_bath", {"time": "2026-03-01T10:00:00"}) + _, body = self.last_upload() + self.assertEqual(body["time"], "2026-03-01 10:00:00 +0000") + + async def test_bottle_in_metric_by_default(self): + before = self.value("bottle_volume_today") + await self.call("log_bottle", {"amount": 120}) + _, body = self.last_upload() + self.assertEqual(body["BCObjectType"], "Formula") + self.assertEqual(body["amount"], {"englishMeasure": False, "value": 120.0}) + self.assertEqual(self.value("bottle_volume_today"), before + 120) + self.assertEqual(self.sensors[f"{BABY_ID}_last_feeding"].extra_state_attributes["type"], "Formula") + + async def test_pumped_bottle_with_explicit_ounces(self): + await self.call("log_bottle", {"amount": 4, "kind": "pumped", "unit": "oz"}) + _, body = self.last_upload() + self.assertEqual(body["BCObjectType"], "Pumped") + self.assertEqual(body["amount"], {"englishMeasure": True, "value": 4.0}) + + async def test_nursing(self): + await self.call("log_nursing", {"left_minutes": 5, "right_minutes": 7, "finish_side": "right"}) + _, body = self.last_upload() + self.assertEqual((body["leftDuration"], body["rightDuration"], body["bothDuration"], body["finishSide"]), (5, 7, 0, "2")) + started = datetime.strptime(body["time"], "%Y-%m-%d %H:%M:%S %z") + self.assertAlmostEqual((datetime.now(timezone.utc) - started).total_seconds(), 720, delta=5) + + async def test_temperature_and_growth(self): + await self.call("log_temperature", {"value": 38.5}) + self.assertEqual(self.value("temperature"), 38.5) + await self.call("log_growth", {"weight": 7.1}) + _, body = self.last_upload() + self.assertEqual(body["weight"], {"englishMeasure": False, "value": 7.1}) + self.assertNotIn("length", body) + self.assertEqual(self.value("weight"), 7.1) + self.assertEqual(self.value("length"), 60) + + async def test_journal_keeps_its_text(self): + await self.call("log_journal", {"note": "First laugh"}) + _, body = self.last_upload() + self.assertEqual((body["BCObjectType"], body["note"]), ("Journal", "First laugh")) + + async def test_invalid_input_is_rejected_before_upload(self): + for service, data in ( + ("log_diaper", {}), + ("log_diaper", {"status": "sparkly"}), + ("log_diaper", {"status": "wet", "amount": "noset"}), + ("log_sleep", {}), + ("log_sleep", {"duration": -1}), + ("log_bottle", {"amount": -5}), + ("log_nursing", {}), + ("log_nursing", {"left_minutes": 0}), + ("log_growth", {}), + ("log_journal", {}), + ("log_journal", {"note": ""}), + ("log_bath", {"unknown": 1}), + ): + with self.subTest(service=service, data=data), self.assertRaises(Exception): + await self.call(service, data) + self.assertEqual(self.state.uploads, []) + + async def test_baby_by_device_id_and_by_name(self): + dr.REGISTRY.devices["device-1"] = dr.DeviceEntry({(DOMAIN, BABY_ID)}) + await self.call("log_bath", {"baby": "device-1"}) + await self.call("log_bath", {"baby": "sam"}) + self.assertEqual(len(self.state.uploads), 2) + + async def test_unknown_baby_is_an_error(self): + with self.assertRaises(ServiceValidationError) as caught: + await self.call("log_bath", {"baby": "Nobody"}) + self.assertEqual(caught.exception.translation_key, "baby_not_found") + + async def test_baby_is_required_with_two_babies(self): + self.state.logs["phone"] = [self.remote_entry(4, {"BCObjectType": "Baby", "objectID": "b2", "name": "Alex"})] + self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}] + await self.coordinator.async_refresh() + with self.assertRaises(ServiceValidationError) as caught: + await self.call("log_bath") + self.assertEqual(caught.exception.translation_key, "baby_required") + await self.call("log_bath", {"baby": "Alex"}) + self.assertEqual(self.last_upload()[1]["baby"]["objectID"], "b2") + + async def test_failed_upload_raises_and_changes_nothing(self): + before = self.value("diapers_today") + self.state.fail_with = 500 + with self.assertRaises(HomeAssistantError) as caught: + await self.call("log_diaper", {"status": "wet"}) + self.assertEqual(caught.exception.translation_key, "upload_failed") + self.assertEqual(self.value("diapers_today"), before) + + self.state.fail_with = None + await self.call("log_diaper", {"status": "wet"}) + self.assertEqual(self.state.uploads[-1]["SyncID"], 1) + + async def test_uploads_keep_order_when_called_together(self): + import asyncio + + await asyncio.gather(*(self.call("log_bath", {"note": str(i)}) for i in range(5))) + self.assertEqual([u["SyncID"] for u in self.state.uploads], [1, 2, 3, 4, 5]) + + async def test_nothing_loaded_means_no_baby(self): + self.entry.state = ConfigEntryState.NOT_LOADED + with self.assertRaises(ServiceValidationError): + await self.call("log_bath") + + +class UsCustomaryTests(IntegrationTestCase): + units = US_CUSTOMARY_SYSTEM + + async def test_units_default_to_home_assistants_system(self): + await self.load() + await self.call("log_bottle", {"amount": 4}) + self.assertIs(self.last_upload()[1]["amount"]["englishMeasure"], True) + await self.call("log_temperature", {"value": 100.4}) + self.assertAlmostEqual(self.value("temperature"), 38.0, places=3) + await self.call("log_growth", {"weight": 11, "length": 20}) + body = self.last_upload()[1] + self.assertIs(body["weight"]["englishMeasure"], True) + self.assertAlmostEqual(self.value("length"), 50.8, places=3) + await self.call("log_bottle", {"amount": 100, "unit": "ml"}) + self.assertIs(self.last_upload()[1]["amount"]["englishMeasure"], False) + + +class DisplayTests(IntegrationTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + await self.load() + + async def test_today_counts_use_the_local_day(self): + from homeassistant.util import dt as dt_util + + now = datetime.now(timezone.utc) + start = now - timedelta(hours=1) + with mock.patch.object(dt_util, "start_of_local_day", return_value=start): + self.assertEqual(self.value("diapers_today"), 1) + with mock.patch.object(dt_util, "start_of_local_day", return_value=now - timedelta(hours=3)): + self.assertEqual(self.value("diapers_today"), 2) + self.assertEqual(self.value("feedings_today"), 2) + + async def test_recent_activity_is_bounded_and_unrecorded(self): + sensor = self.sensors[f"{BABY_ID}_recent_activity"] + self.assertEqual(sensor._unrecorded_attributes, frozenset({"entries"})) + for i in range(25): + await self.call("log_bath", {"note": str(i)}) + entries = sensor.extra_state_attributes["entries"] + self.assertEqual(len(entries), 20) + self.assertEqual(set(entries[0]), {"id", "type", "summary", "time", "note"}) + json.dumps(entries) + self.assertEqual(sensor.native_value, self.coordinator.data.latest(BABY_ID).time) + + async def test_calendar_window_and_durations(self): + calendar = self.calendars[0] + now = datetime.now(timezone.utc) + events = await calendar.async_get_events(self.hass, now - timedelta(hours=4), now) + by_uid = {event.uid: event for event in events} + self.assertIn("s-1", by_uid) + self.assertEqual(by_uid["s-1"].end - by_uid["s-1"].start, timedelta(minutes=45)) + self.assertEqual(by_uid["d-new"].end - by_uid["d-new"].start, timedelta(minutes=1)) + self.assertEqual(by_uid["d-new"].description, "messy") + self.assertNotIn("n-1", by_uid) + self.assertEqual(calendar.event.uid, "d-new") + + async def test_calendar_includes_sleep_overlapping_the_window(self): + calendar = self.calendars[0] + start = datetime.fromtimestamp(self.now - 3 * 3600 + 600, timezone.utc) + events = await calendar.async_get_events(self.hass, start, start + timedelta(minutes=5)) + self.assertEqual([event.uid for event in events], ["s-1"]) + + async def test_no_entries(self): + self.state.seed = None + self.state.devices = [] + self.state.logs = {"phone": [self.remote_entry(4, {"BCObjectType": "Baby", "objectID": "b3", "name": "Kim"})]} + self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}] + await self.load() + self.assertIsNone(self.value("last_diaper", "b3")) + self.assertEqual(self.value("diapers_today", "b3"), 0) + self.assertEqual(self.value("sleep_today", "b3"), 0) + self.assertIsNone(self.value("weight", "b3")) + self.assertEqual(self.sensors["b3_last_diaper"].extra_state_attributes, {}) + calendar = next(c for c in self.calendars if c.baby_id == "b3") + self.assertIsNone(calendar.event) + + +class ConfigFlowTests(IntegrationTestCase): + def flow(self, existing=()): + flow = config_flow.BabyTrackerConfigFlow() + flow.hass = self.hass + flow.existing_unique_ids = set(existing) + return flow + + async def test_shows_form_first(self): + result = await self.flow().async_step_user() + self.assertEqual((result["type"], result["step_id"]), ("form", "user")) + + async def test_creates_entry_with_a_lasting_device_id(self): + result = await self.flow().async_step_user({"email": self.state.email, "password": self.state.password}) + self.assertEqual(result["type"], "create_entry") + self.assertEqual(result["title"], self.state.email) + self.assertEqual(result["data"][CONF_DEVICE_UUID], self.state.login_bodies[0]["Device"]["DeviceUUID"]) + self.assertEqual(self.state.login_bodies[0]["Device"]["DeviceName"], "Home Assistant") + + async def test_wrong_password(self): + result = await self.flow().async_step_user({"email": self.state.email, "password": "nope"}) + self.assertEqual(result["errors"], {"base": "invalid_auth"}) + + async def test_server_error(self): + self.state.login_status = 500 + result = await self.flow().async_step_user({"email": self.state.email, "password": self.state.password}) + self.assertEqual(result["errors"], {"base": "cannot_connect"}) + + async def test_307(self): + self.state.login_status = 307 + result = await self.flow().async_step_user({"email": self.state.email, "password": self.state.password}) + self.assertEqual(result["errors"], {"base": "account_state"}) + + async def test_unreachable_server(self): + client = partial(BabyTrackerClient, base_url="http://127.0.0.1:9", timeout=2) + with mock.patch.object(config_flow, "BabyTrackerClient", client): + result = await self.flow().async_step_user({"email": "a@b.c", "password": "x"}) + self.assertEqual(result["errors"], {"base": "cannot_connect"}) + + async def test_same_account_twice_aborts(self): + with self.assertRaises(AbortFlow) as caught: + await self.flow(existing={"42"}).async_step_user({"email": self.state.email, "password": self.state.password}) + self.assertEqual(caught.exception.reason, "already_configured") + + async def test_reauth_updates_password_and_keeps_device_id(self): + entry = self.make_entry() + flow = self.flow() + flow.reauth_entry = entry + result = await flow.async_step_reauth(entry.data) + self.assertEqual(result["step_id"], "reauth_confirm") + self.assertEqual(result["description_placeholders"], {"email": self.state.email}) + + self.state.password = "new-password" + result = await flow.async_step_reauth_confirm({"password": "wrong"}) + self.assertEqual(result["errors"], {"base": "invalid_auth"}) + result = await flow.async_step_reauth_confirm({"password": "new-password"}) + self.assertEqual(result["reason"], "reauth_successful") + self.assertEqual(entry.data["password"], "new-password") + self.assertEqual(entry.data[CONF_DEVICE_UUID], DEVICE_UUID) + self.assertEqual(self.state.login_bodies[-1]["Device"]["DeviceUUID"], DEVICE_UUID) + + async def test_reauth_with_another_account_aborts(self): + entry = ConfigEntry({"email": self.state.email, "password": "x", CONF_DEVICE_UUID: DEVICE_UUID}, "999") + flow = self.flow() + flow.reauth_entry = entry + with self.assertRaises(AbortFlow) as caught: + await flow.async_step_reauth_confirm({"password": self.state.password}) + self.assertEqual(caught.exception.reason, "wrong_account") + + +class PackagingTests(unittest.TestCase): + """Checks that the integration's data files agree with its code.""" + + BASE = ROOT / "custom_components" / "babytracker" + + @classmethod + def setUpClass(cls): + import yaml + + cls.services = yaml.safe_load((cls.BASE / "services.yaml").read_text()) + cls.translations = json.loads((cls.BASE / "translations" / "en.json").read_text()) + cls.icons = json.loads((cls.BASE / "icons.json").read_text()) + cls.manifest = json.loads((cls.BASE / "manifest.json").read_text()) + from custom_components.babytracker import services + + cls.schemas = {} + for name, (_, schema) in services._SERVICES.items(): + schema = schema.validators[0] if hasattr(schema, "validators") else schema + cls.schemas[name] = {marker.key for marker in schema.schema} + + def test_services_yaml_matches_the_schemas(self): + declared = {name: set(body["fields"]) for name, body in self.services.items() if not name.startswith(".")} + self.assertEqual(declared, self.schemas) + + def test_every_service_field_is_translated_and_has_an_icon(self): + for name, fields in self.schemas.items(): + translated = self.translations["services"][name] + self.assertEqual(set(translated["fields"]), fields, name) + self.assertIn(name, self.icons["services"]) + + def test_every_sensor_is_translated(self): + keys = {description.key for description in sensor_platform.SENSORS} | {"recent_activity"} + self.assertEqual(set(self.translations["entity"]["sensor"]), keys) + self.assertLessEqual(set(self.icons["entity"]["sensor"]), keys) + + def test_every_error_is_translated(self): + source = (self.BASE / "config_flow.py").read_text() + for key in self.translations["config"]["error"]: + self.assertIn(f'"{key}"', source) + import re + + services_source = (self.BASE / "services.py").read_text() + keys = set(re.findall(r'translation_key="(\w+)"', services_source)) + self.assertEqual(keys, set(self.translations["exceptions"])) + + def test_manifest_has_what_hacs_requires(self): + for key in ("domain", "name", "codeowners", "documentation", "issue_tracker", "version", "config_flow"): + self.assertIn(key, self.manifest) + self.assertEqual(self.manifest["domain"], DOMAIN) + hacs = json.loads((ROOT / "hacs.json").read_text()) + self.assertIn("name", hacs) + + def test_library_needs_only_what_the_manifest_declares(self): + import re + + for path in (self.BASE / "babytracker_api").glob("*.py"): + for module in re.findall(r"^(?:from|import) (\w+)", path.read_text(), re.M): + self.assertNotIn(module, ("homeassistant", "custom_components"), path.name) + self.assertEqual(self.manifest["requirements"], ["requests>=2.25"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_replica.py b/tests/test_replica.py new file mode 100644 index 0000000..dbfee3e --- /dev/null +++ b/tests/test_replica.py @@ -0,0 +1,197 @@ +import sys +import tempfile +import time +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import load_library # noqa: F401 +from seed_fixture import BABY_ID, build_seed_db + +from babytracker_api import ( + DiaperStatus, + FEED_KINDS, + OpCode, + Replica, + decode_object, + diaper, + encode_object, + format_date, + formula, + open_seed_db, + read_cursors, + sleep, +) + +NOW = time.time() +BABY = {"objectID": BABY_ID, "name": "Sam", "newFlage": False} + + +def wire(record): + """Round-trip a record the way it travels in a transaction.""" + return decode_object(encode_object(record)) + + +def stamped(record, seconds): + return {**record, "timestamp": format_date(datetime.fromtimestamp(NOW + seconds, timezone.utc))} + + +class SeedTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.folder = tempfile.TemporaryDirectory() + cls.connection = open_seed_db(build_seed_db(Path(cls.folder.name) / "seed.db", NOW)) + + @classmethod + def tearDownClass(cls): + cls.connection.close() + cls.folder.cleanup() + + def setUp(self): + self.replica = Replica.from_seed(self.connection) + + def details(self, entry_id): + return next(e for e in self.replica.entries(BABY_ID) if e.id == entry_id).details + + def test_reads_babies_and_cursors(self): + self.assertEqual(self.replica.babies[BABY_ID].name, "Sam") + self.assertIs(self.replica.babies[BABY_ID].record["gender"], True) + self.assertEqual(read_cursors(self.connection), {"phone": 3}) + + def test_entries_are_newest_first(self): + times = [entry.time for entry in self.replica.entries(BABY_ID)] + self.assertEqual(times, sorted(times, reverse=True)) + self.assertEqual(self.replica.latest(BABY_ID).id, "d-new") + + def test_diaper_names_and_the_apps_dry_rule(self): + self.assertEqual(self.details("d-new"), {"status": "poopy", "amount": "heavy"}) + self.assertEqual(self.details("d-dry"), {"status": "dry", "amount": None}) + + def test_units_become_metric(self): + self.assertAlmostEqual(self.details("f-oz")["volume_ml"], 118.294, places=2) + self.assertEqual(self.details("f-ml")["volume_ml"], 100) + self.assertAlmostEqual(self.details("t-1")["celsius"], 37.0, places=3) + self.assertAlmostEqual(self.details("g-new")["weight_kg"], 6.0, places=3) + + def test_unset_growth_columns_are_none(self): + self.assertIsNone(self.details("g-new")["length_cm"]) + self.assertEqual(self.details("g-old")["length_cm"], 60) + + def test_selection_names_are_joined(self): + self.assertEqual(self.details("m-1")["name"], "Vitamin D") + + def test_nursing_total_gives_the_end_time(self): + entry = next(e for e in self.replica.entries(BABY_ID) if e.id == "n-1") + self.assertEqual(entry.details["duration"], 12) + self.assertEqual(entry.end - entry.time, timedelta(minutes=12)) + self.assertEqual(entry.details["finish_side"], "right") + self.assertEqual(entry.summary, "Nursing: left 5 min, right 7 min") + + def test_summaries(self): + by_id = {entry.id: entry.summary for entry in self.replica.entries(BABY_ID)} + self.assertEqual(by_id["d-new"], "Diaper: poopy, heavy") + self.assertEqual(by_id["f-ml"], "Formula: 100 ml") + self.assertEqual(by_id["s-1"], "Sleep: 45 min") + self.assertEqual(by_id["m-1"], "Medication: Vitamin D") + self.assertEqual(by_id["t-1"], "Temperature: 37.0 °C") + self.assertEqual(by_id["k-1"], "Sick") + + def test_pump_belongs_to_no_baby(self): + self.assertEqual([entry.id for entry in self.replica.entries(None)], ["p-1"]) + + def test_since_and_kind_filters(self): + start = datetime.fromtimestamp(NOW - 4 * 3600, timezone.utc) + self.assertEqual([e.id for e in self.replica.since(BABY_ID, start, {"Diaper"})], ["d-new", "d-dry"]) + self.assertEqual({e.kind for e in self.replica.entries(BABY_ID, FEED_KINDS)}, {"Formula", "Nursing"}) + + def test_missing_tables_are_skipped(self): + path = Path(self.folder.name) / "tiny.db" + import sqlite3 + + connection = sqlite3.connect(path) + connection.execute('CREATE TABLE "Baby" (ID, Timestamp, Name, DOB, DueDay, Gender, Picture)') + connection.commit() + connection.close() + tiny = open_seed_db(path) + self.addCleanup(tiny.close) + self.assertEqual(Replica.from_seed(tiny).babies, {}) + + +class LogTests(unittest.TestCase): + def setUp(self): + self.replica = Replica() + self.replica.apply(OpCode.INSERT, {"BCObjectType": "Baby", **BABY}) + + def test_insert_update_delete(self): + record = sleep(BABY, 30) + self.replica.apply(OpCode.INSERT, stamped(wire(record), 0)) + self.assertEqual(self.replica.latest(BABY_ID).details["duration"], 30) + self.replica.apply(OpCode.UPDATE, stamped(wire({**record, "duration": 40}), 10)) + self.assertEqual(self.replica.latest(BABY_ID).details["duration"], 40) + self.replica.apply(OpCode.DELETE, stamped(wire(record), 20)) + self.assertIsNone(self.replica.latest(BABY_ID)) + + def test_older_update_does_not_replace_newer(self): + record = sleep(BABY, 30) + self.replica.apply(OpCode.UPDATE, stamped(wire(record), 10)) + self.replica.apply(OpCode.UPDATE, stamped(wire({**record, "duration": 99}), 5)) + self.assertEqual(self.replica.latest(BABY_ID).details["duration"], 30) + + def test_older_update_does_not_undo_delete(self): + record = sleep(BABY, 30) + self.replica.apply(OpCode.DELETE, stamped(wire(record), 10)) + self.replica.apply(OpCode.UPDATE, stamped(wire(record), 5)) + self.assertIsNone(self.replica.latest(BABY_ID)) + + def test_enums_read_from_the_wire(self): + self.replica.apply(OpCode.INSERT, wire(diaper(BABY, DiaperStatus.MIXED))) + self.assertEqual(self.replica.latest(BABY_ID).details, {"status": "mixed", "amount": None}) + + def test_english_bottle(self): + self.replica.apply(OpCode.INSERT, wire(formula(BABY, 1, english=True))) + self.assertAlmostEqual(self.replica.latest(BABY_ID).details["volume_ml"], 29.5735) + + def test_localized_date_is_understood(self): + record = {**wire(sleep(BABY, 1)), "time": "2026-01-02 03:00:05 nachm. +0000"} + self.replica.apply(OpCode.INSERT, record) + self.assertEqual(self.replica.latest(BABY_ID).time.hour, 15) + + def test_unknown_type_with_a_time_is_kept(self): + record = {"BCObjectType": "FutureThing", "objectID": "x", "baby": BABY, "time": "2026-01-02 03:00:05 +0000"} + self.replica.apply(OpCode.INSERT, record) + self.assertEqual(self.replica.latest(BABY_ID).summary, "FutureThing") + + def test_selection_and_picture_records_are_ignored(self): + self.replica.apply(OpCode.INSERT, {"BCObjectType": "OtherFeedSelection", "objectID": "s", "name": "Fruit"}) + self.replica.apply(OpCode.INSERT, {"BCObjectType": "Picture", "objectID": "p", "fileName": "p"}) + self.assertIsNone(self.replica.latest(BABY_ID)) + + def test_baby_added_renamed_and_deleted(self): + other = {"BCObjectType": "Baby", "objectID": "b2", "name": "Alex"} + self.replica.apply(OpCode.INSERT, other) + self.replica.apply(OpCode.UPDATE, {**other, "name": "Alexa"}) + self.assertEqual(self.replica.babies["b2"].name, "Alexa") + self.replica.apply(OpCode.DELETE, other) + self.assertNotIn("b2", self.replica.babies) + + def test_conflict_moves_entries_to_the_winner(self): + loser = {"BCObjectType": "Baby", "objectID": "b2", "name": "Sam"} + self.replica.apply(OpCode.INSERT, loser) + self.replica.apply(OpCode.INSERT, wire(sleep({"objectID": "b2"}, 15))) + winner = {"BCObjectType": "Baby", **BABY} + self.replica.apply(OpCode.CONFLICT, {"BCObjectType": "ConflictRecord", "objectID": "c", "winner": winner, "loser": loser}) + self.assertNotIn("b2", self.replica.babies) + self.assertIn(BABY_ID, self.replica.babies) + self.assertEqual(self.replica.latest(BABY_ID).details["duration"], 15) + self.assertIsNone(self.replica.latest("b2")) + + def test_selection_conflict_changes_nothing(self): + selection = {"BCObjectType": "OtherFeedSelection", "objectID": "s"} + self.replica.apply(OpCode.CONFLICT, {"BCObjectType": "ConflictRecord", "winner": selection, "loser": selection}) + self.assertEqual(list(self.replica.babies), [BABY_ID]) + + +if __name__ == "__main__": + unittest.main()