Add Baby Tracker client and Home Assistant integration

Python client for the Baby Tracker sync server, reverse engineered from
the Android app (4.53), and a HACS-installable Home Assistant integration
built on it. Each baby gets a log calendar and summary sensors, and actions
add entries that sync back to the group.

Reading has been run against the real server. Uploading and running inside
real Home Assistant have not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
arneandClaude Opus 5.5 committed 2026-11-04 10:04:39 +00:00
commit 6466f002e0
51 files changed
+5199

No files matched your search

+44
View File
@@ -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)
@@ -0,0 +1,87 @@
"""Unofficial Python client for the Baby Tracker sync server."""
from .client import (
AccountChangedError,
AccountStateError,
AuthenticationError,
BabyTrackerClient,
BabyTrackerError,
ConflictError,
MissingSeedError,
NotFoundError,
)
from .models import (
Device,
DiaperAmount,
DiaperStatus,
MediaType,
NursingFinishSide,
OpCode,
PumpFinishSide,
PumpSides,
Transaction,
bath,
decode_object,
diaper,
encode_object,
format_date,
formula,
growth,
journal,
joy,
measure,
new_activity,
nursing,
parse_date,
pumped,
sleep,
temperature,
)
from .replica import BOTTLE_KINDS, FEED_KINDS, Baby, Entry, Replica
from .seed import extract_seed_db, open_seed_db, read_babies, read_cursors, read_records
__all__ = [
"AccountChangedError",
"AccountStateError",
"AuthenticationError",
"BOTTLE_KINDS",
"Baby",
"BabyTrackerClient",
"BabyTrackerError",
"ConflictError",
"Device",
"DiaperAmount",
"DiaperStatus",
"Entry",
"FEED_KINDS",
"MediaType",
"MissingSeedError",
"NotFoundError",
"NursingFinishSide",
"OpCode",
"PumpFinishSide",
"PumpSides",
"Replica",
"Transaction",
"bath",
"decode_object",
"diaper",
"encode_object",
"extract_seed_db",
"format_date",
"formula",
"growth",
"journal",
"joy",
"measure",
"new_activity",
"nursing",
"open_seed_db",
"parse_date",
"pumped",
"read_babies",
"read_cursors",
"read_records",
"sleep",
"temperature",
]
@@ -0,0 +1,371 @@
"""HTTP client for the Baby Tracker sync server.
Reconstructed from com.nighp.babytracker_android.sync.SyncImple (app 4.53).
See docs/API.md for the protocol and for what is inferred rather than observed.
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Iterator, Mapping, MutableMapping, Optional, Union
from urllib.parse import quote
import requests
from .models import Device, OpCode, Transaction
DEFAULT_BASE_URL = "https://prodapp.babytrackers.com"
PAGE_SIZE = 500
PHOTO_BOUNDARY = "__BABY_TRACKER_BOUNDARY____Photo__"
SEED_BOUNDARY = "__BABY_TRACKER_BOUNDARY____SEED__"
# The app sends zeros: App_Info's constructor sets both and nothing changes them.
_APP_INFO = {"AccountType": 0, "AppType": 0}
_TOKEN_APP_TYPE = 5
PathLike = Union[str, Path]
class BabyTrackerError(Exception):
"""The server returned an unexpected status."""
def __init__(self, status: int, message: str = ""):
super().__init__(f"HTTP {status}: {message}" if message else f"HTTP {status}")
self.status = status
self.message = message
class AuthenticationError(BabyTrackerError):
"""Bad credentials, or a session that could not be renewed (400 or 401)."""
class AccountStateError(BabyTrackerError):
"""Login returned 307. The app turns sync off; the cause is not documented."""
class ConflictError(BabyTrackerError):
"""409, for example when creating a group that already exists."""
class NotFoundError(BabyTrackerError):
"""404: no such seed or picture."""
class MissingSeedError(BabyTrackerError):
"""424: a group was finalized before its seed was uploaded."""
class AccountChangedError(Exception):
"""A renewed session belongs to another account than the first login.
The app turns sync off in this case, as merging would mix two accounts.
"""
_ERRORS_BY_STATUS = {
400: AuthenticationError,
401: AuthenticationError,
307: AccountStateError,
404: NotFoundError,
409: ConflictError,
424: MissingSeedError,
}
class BabyTrackerClient:
"""A sync client, which the server sees as one more device on the account.
Reuse the same ``device_uuid`` between runs: it identifies this client's
transaction log. A new value is a new device on the account.
"""
def __init__(
self,
email: str,
password: str,
device_uuid: Optional[str] = None,
device_name: str = "Python client",
device_os_info: str = "Python",
base_url: str = DEFAULT_BASE_URL,
timeout: float = 60,
):
self.email = email
self.password = password
self.device_uuid = device_uuid or str(uuid.uuid4())
self.device_name = device_name
self.device_os_info = device_os_info
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.account_id: Optional[int] = None
self._session = requests.Session()
self._next_sync_id: Optional[int] = None
# ------------------------------------------------------------------
# Transport
# ------------------------------------------------------------------
def _send(self, method: str, path: str, **kwargs: Any) -> requests.Response:
# HttpURLConnection follows redirects for GET only. A 307 on POST reaches
# the app's error handling, so it must not be followed here either.
return self._session.request(
method,
self.base_url + path,
timeout=self.timeout,
allow_redirects=method == "GET",
**kwargs,
)
def _send_json(self, method: str, path: str, body: Mapping[str, Any]) -> requests.Response:
return self._send(
method,
path,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json", "charset": "utf-8"},
)
@staticmethod
def _check(response: requests.Response, *expected: int) -> requests.Response:
if response.status_code in expected:
return response
error = _ERRORS_BY_STATUS.get(response.status_code, BabyTrackerError)
raise error(response.status_code, response.text.strip())
def _authed(self, send: Any, *expected: int) -> requests.Response:
"""Run a request, logging in again once if the session has expired."""
response = send()
if response.status_code == 401:
self.login()
response = send()
return self._check(response, *expected)
def _user_info(self) -> dict[str, Any]:
return {
"AppInfo": _APP_INFO,
"Device": {
"DeviceName": self.device_name,
"DeviceOSInfo": self.device_os_info,
"DeviceUUID": self.device_uuid,
},
"EmailAddress": self.email,
"Password": self.password,
}
def _remember_account(self, response: requests.Response) -> int:
account_id = response.json().get("AccountID")
if not account_id:
raise BabyTrackerError(response.status_code, "response has no AccountID")
if self.account_id is not None and account_id != self.account_id:
raise AccountChangedError(f"logged in to account {account_id}, expected {self.account_id}")
self.account_id = account_id
return account_id
# ------------------------------------------------------------------
# Session
# ------------------------------------------------------------------
def login(self) -> int:
"""Start a session and return the account ID.
The request carries this device's details, so expect it to be added to
the account's devices.
"""
response = self._check(self._send_json("POST", "/session", self._user_info()), 200)
return self._remember_account(response)
def logout(self) -> None:
self._authed(lambda: self._send("DELETE", "/session"), 200)
def forgot_password(self, language: str = "en") -> None:
"""Ask the server to send a password reset email."""
body = {"EmailAddress": self.email, "Language": language, "AppInfo": _APP_INFO}
self._check(self._send_json("POST", "/account/forgot_password", body), 200)
def register_push_token(self, token: str) -> None:
"""Register a Firebase token for "new data" push notifications."""
body = {"TokenNative": token, "TokenAppType": _TOKEN_APP_TYPE}
self._authed(lambda: self._send_json("POST", "/device_token", body), 200, 201)
# ------------------------------------------------------------------
# Reading the account
# ------------------------------------------------------------------
def get_devices(self) -> list[Device]:
response = self._authed(lambda: self._send("GET", "/account/device"), 200)
return [Device.from_json(item) for item in response.json()]
def get_transactions(self, device_uuid: str, after_sync_id: int = 0) -> list[Transaction]:
"""Fetch one page of a device's log, starting after ``after_sync_id``."""
path = f"/account/transaction/{quote(device_uuid, safe='')}/{int(after_sync_id)}"
response = self._authed(lambda: self._send("GET", path), 200)
return [Transaction.from_json(item) for item in response.json() or []]
def iter_transactions(self, device_uuid: str, after_sync_id: int = 0) -> Iterator[Transaction]:
"""Iterate over a device's log, fetching pages until a short one."""
cursor = after_sync_id
while True:
page = self.get_transactions(device_uuid, cursor)
for item in page:
if item.sync_id > cursor:
yield item
newest = max((item.sync_id for item in page), default=cursor)
# Also stop if a full page did not advance, rather than loop forever.
if len(page) < PAGE_SIZE or newest <= cursor:
return
cursor = newest
def pull(
self, cursors: MutableMapping[str, int], include_own: bool = False
) -> Iterator[tuple[str, Transaction]]:
"""Yield ``(device_uuid, transaction)`` for everything not yet seen.
``cursors`` maps device UUIDs to the last SyncID handled and is updated
after each item is consumed, so it can be saved and passed to a later
call. This device's own log is skipped, as in the app, unless
``include_own`` is set. A client rebuilding its state from the seed needs
it, because the seed does not contain what this device uploaded later.
"""
for device in self.get_devices():
if device.uuid == self.device_uuid and not include_own:
continue
cursor = cursors.get(device.uuid, 0)
if cursor >= device.last_sync_id:
continue
for item in self.iter_transactions(device.uuid, cursor):
yield device.uuid, item
cursors[device.uuid] = item.sync_id
def download_seed(self, dest_path: PathLike) -> Path:
"""Download the account's seed zip. See :mod:`babytracker_api.seed`."""
return self._download("/account/seed", dest_path)
def download_picture(self, file_name: str, dest_path: PathLike) -> Path:
"""Download a picture.
``file_name`` is a server file name. For a ``Picture`` record with
``fileName`` X these are ``X.jpg`` (``X.mp4`` for a video) and
``X__thumbnail.jpg``.
"""
return self._download(f"/account/picture/{quote(file_name, safe='')}", dest_path)
def _download(self, path: str, dest_path: PathLike) -> Path:
dest = Path(dest_path)
response = self._authed(lambda: self._send("GET", path, stream=True), 200)
with dest.open("wb") as target:
for chunk in response.iter_content(chunk_size=65536):
target.write(chunk)
return dest
# ------------------------------------------------------------------
# Writing to the account
# ------------------------------------------------------------------
def upload_transaction(self, transaction: Transaction) -> None:
self._authed(lambda: self._send_json("POST", "/account/transaction", transaction.to_json()), 201)
def next_sync_id(self) -> int:
"""Return the next SyncID for this device's log.
The app counts these locally. This client resumes from what the server
reports, then counts locally, so only one process per ``device_uuid``
may upload at a time.
"""
if self._next_sync_id is None:
last = next((d.last_sync_id for d in self.get_devices() if d.uuid == self.device_uuid), 0)
self._next_sync_id = last + 1
return self._next_sync_id
def _push(self, op: OpCode, obj: Mapping[str, Any]) -> Transaction:
transaction = Transaction.create(self.next_sync_id(), op, obj)
self.upload_transaction(transaction)
self._next_sync_id = transaction.sync_id + 1
return transaction
def add(self, obj: Mapping[str, Any]) -> Transaction:
"""Upload a new record, as built by the helpers in ``models``."""
return self._push(OpCode.INSERT, obj)
def update(self, obj: Mapping[str, Any]) -> Transaction:
"""Upload a changed record. Other devices replace their copy with it."""
return self._push(OpCode.UPDATE, {**obj, "timestamp": datetime.now(timezone.utc)})
def delete(self, obj: Mapping[str, Any]) -> Transaction:
"""Upload a deletion. The whole record is sent, not just its ID."""
return self._push(OpCode.DELETE, {**obj, "timestamp": datetime.now(timezone.utc)})
def upload_picture(self, path: PathLike, staging: bool = False) -> None:
"""Upload a picture. ``staging`` is for pictures of an unfinished group."""
url = "/staging_account/picture" if staging else "/account/picture"
self._upload_file(url, Path(path), "picture", "image/jpeg", PHOTO_BOUNDARY)
def upload_clone_data(self, path: PathLike, file_name: Optional[str] = None) -> None:
"""Upload a backup zip. The app sends one when a device joins a group.
The app names the file after the device ID and carries on joining if
this upload fails. The app has no call that downloads this data again.
"""
source = Path(path)
self._upload_file(
"/account/clonedata",
source,
"clonedata",
"application/zip",
PHOTO_BOUNDARY,
file_name=file_name or source.name,
)
def _upload_file(
self,
path: str,
source: Path,
field: str,
content_type: str,
boundary: str,
file_name: Optional[str] = None,
) -> None:
head = (
f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field}"; filename="{file_name or source.name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
tail = f"\r\n--{boundary}--\r\n".encode("utf-8")
body = head + source.read_bytes() + tail
headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"}
self._authed(lambda: self._send("POST", path, data=body, headers=headers), 201)
# ------------------------------------------------------------------
# Group management
# ------------------------------------------------------------------
def create_group(self, seed_zip: PathLike, pictures: Iterable[PathLike] = ()) -> int:
"""Create an account from a seed zip holding the app's database.
Returns the new account ID. Raises :class:`ConflictError` if the email
is already in use.
"""
self._check(self._send_json("POST", "/staging_account", self._user_info()), 200)
return self._finish_group(seed_zip, pictures)
def reset_group(self, seed_zip: PathLike, pictures: Iterable[PathLike] = (), *, confirm: bool = False) -> int:
"""Replace the whole account with a seed zip.
Judging by the endpoint and the app's flow, this discards the account's
server-side data and pushes its other devices out of sync, so
``confirm=True`` is required. It has not been tried against the server.
"""
if not confirm:
raise ValueError("reset_group replaces all account data; pass confirm=True")
self._check(self._send_json("POST", "/account_reset", self._user_info()), 200)
return self._finish_group(seed_zip, pictures)
def _finish_group(self, seed_zip: PathLike, pictures: Iterable[PathLike]) -> int:
self._upload_file("/staging_account/seed", Path(seed_zip), "seed", "application/zip", SEED_BOUNDARY, "seed.zip")
for picture in pictures:
self.upload_picture(picture, staging=True)
response = self._check(self._send("PUT", "/staging_account"), 200)
self.account_id = None
self._next_sync_id = None
return self._remember_account(response)
@@ -0,0 +1,344 @@
"""Wire format for Baby Tracker sync records.
A record is a JSON object that travels base64 encoded inside a transaction.
Field names mirror the app's Java classes because the app serializes them with
Gson reflection. Records stay plain dicts so that fields this module does not
know about survive a download/modify/upload round trip.
"""
from __future__ import annotations
import base64
import json
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import IntEnum
from typing import Any, Mapping, Optional
TYPE_FIELD = "BCObjectType"
# Types registered with the app's RuntimeTypeAdapterFactory (utility/GsonUtils).
# The Android app rejects a record whose BCObjectType is not in this set.
KNOWN_TYPES = frozenset(
{
"Activity", "Baby", "BabyActivity", "Bath", "Bottle", "ConflictRecord",
"Diaper", "EditableSelection", "Feed", "Formula", "Growth", "Journal",
"Joy", "Medication", "MedicationSelection", "Milestone",
"MilestoneSelection", "Nursing", "NursingSession", "OtherActivity",
"OtherActivityDescription", "OtherFeed", "OtherFeedSelection", "Picture",
"Pump", "Pumped", "Sleep", "Temperature", "Vaccine", "VaccineSelection",
}
)
class OpCode(IntEnum):
INSERT = 0
UPDATE = 1
DELETE = 2
RELIVE = 3
CONFLICT = 4
class DiaperStatus(IntEnum):
WET = 0
POOPY = 1
MIXED = 2
DRY = 3
class DiaperAmount(IntEnum):
NONE = 0
LIGHT = 1
NORMAL = 2
HEAVY = 3
OVERFLOW = 4
NOSET = 5
class NursingFinishSide(IntEnum):
NOT_SET = 0
LEFT = 1
RIGHT = 2
class PumpFinishSide(IntEnum):
NONE = 0
LEFT = 1
RIGHT = 2
class PumpSides(IntEnum):
BOTH = 0
LEFT = 1
RIGHT = 2
class MediaType(IntEnum):
PICTURE = 0
VIDEO = 1
AUDIO = 2
# ---------------------------------------------------------------------------
# Dates
# ---------------------------------------------------------------------------
# Older app versions wrote localized AM/PM markers, and DateTypeAdapter falls
# back to rewriting them. Order matches the app.
_MERIDIEM_MARKERS = (
("a.m.", "AM"), ("p.m.", "PM"), ("vorm.", "AM"), ("nachm.", "PM"),
("午前", "AM"), ("午後", "PM"), ("上午", "AM"), ("下午", "PM"),
("a. m.", "AM"), ("p. m.", "PM"), ("fm", "AM"), ("em", "PM"),
("오전", "AM"), ("오후", "PM"),
)
def format_date(value: datetime) -> str:
"""Format a datetime as the app does: ``yyyy-MM-dd HH:mm:ss Z`` in GMT."""
if value.tzinfo is None:
raise ValueError("datetime must be timezone aware")
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S +0000")
def parse_date(value: str) -> datetime:
"""Parse any date form accepted by the app's DateTypeAdapter."""
try:
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S %z")
except ValueError:
pass
normalized = value
for marker, meridiem in _MERIDIEM_MARKERS:
normalized = normalized.replace(marker, meridiem)
return datetime.strptime(normalized, "%Y-%m-%d %I:%M:%S %p %z")
def _now() -> datetime:
return datetime.now(timezone.utc)
def _raw_utc_offset(moment: datetime) -> int:
"""The app's ``timezone`` field: UTC offset in seconds, excluding DST."""
local = moment.astimezone()
offset = local.utcoffset() or timedelta(0)
dst = local.dst() or timedelta(0)
return int((offset - dst).total_seconds())
# ---------------------------------------------------------------------------
# Record encoding
# ---------------------------------------------------------------------------
def _json_default(value: Any) -> Any:
if isinstance(value, datetime):
return format_date(value)
raise TypeError(f"cannot serialize {type(value).__name__}")
def _gson_enums(value: Any) -> Any:
# Gson writes @SerializedName enums as strings, so DiaperStatus.POOPY is "1".
# IntEnum is a JSON number to the json module, so this has to run first.
if isinstance(value, IntEnum):
return str(int(value))
if isinstance(value, Mapping):
return {key: _gson_enums(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_gson_enums(item) for item in value]
return value
def encode_object(obj: Mapping[str, Any]) -> str:
"""Encode a record for the ``Transaction`` field of a transaction."""
if TYPE_FIELD not in obj:
raise ValueError(f"record has no {TYPE_FIELD}")
text = json.dumps(_gson_enums(obj), indent=2, ensure_ascii=False, default=_json_default)
return base64.b64encode(text.encode("utf-8")).decode("ascii")
def decode_object(encoded: str) -> dict[str, Any]:
"""Decode the ``Transaction`` field of a transaction into a record."""
return json.loads(base64.b64decode(encoded).decode("utf-8"))
@dataclass(frozen=True)
class Device:
"""A device on the account and the newest SyncID the server holds for it."""
uuid: str
last_sync_id: int
@classmethod
def from_json(cls, data: Mapping[str, Any]) -> "Device":
return cls(uuid=data["DeviceUUID"], last_sync_id=int(data["LastSyncID"]))
@dataclass(frozen=True)
class Transaction:
"""One entry of a device's transaction log."""
sync_id: int
op: OpCode
payload: str
@classmethod
def from_json(cls, data: Mapping[str, Any]) -> "Transaction":
# OPCode is a string when written by Gson. Gson also reads numbers here,
# so int() accepts both forms.
return cls(
sync_id=int(data["SyncID"]),
op=OpCode(int(data["OPCode"])),
payload=data["Transaction"],
)
@classmethod
def create(cls, sync_id: int, op: OpCode, obj: Mapping[str, Any]) -> "Transaction":
return cls(sync_id=sync_id, op=op, payload=encode_object(obj))
def to_json(self) -> dict[str, Any]:
return {"SyncID": self.sync_id, "OPCode": str(int(self.op)), "Transaction": self.payload}
@property
def object(self) -> dict[str, Any]:
return decode_object(self.payload)
@property
def object_type(self) -> Optional[str]:
return self.object.get(TYPE_FIELD)
# ---------------------------------------------------------------------------
# Measures. Units follow get*OnInternational() in the app's *Measure classes.
# ---------------------------------------------------------------------------
def measure(value: float, english: bool = False) -> dict[str, Any]:
"""Build a measure, in the unit given by ``english``.
======== ======== =======
kind metric english
======== ======== =======
volume ml fl oz
weight kg lb
length cm in
temp °C °F
======== ======== =======
"""
return {"englishMeasure": english, "value": float(value)}
# ---------------------------------------------------------------------------
# Record builders
# ---------------------------------------------------------------------------
def new_activity(
type_name: str,
baby: Optional[Mapping[str, Any]],
time: Optional[datetime] = None,
note: str = "",
**fields: Any,
) -> dict[str, Any]:
"""Build a new activity record, with the defaults of the app's constructors.
``baby`` is the full baby record, nested without its own BCObjectType. Pass
``None`` only for ``Pump``, the one activity type that has no baby.
Extra keyword arguments are written as record fields under the given name.
"""
now = _now()
moment = time or now
record: dict[str, Any] = {
TYPE_FIELD: type_name,
"note": note,
"pictureLoaded": True,
"pictureNote": [],
"time": moment,
"timezone": _raw_utc_offset(moment),
"newFlage": True,
"objectID": str(uuid.uuid4()),
"timestamp": now,
}
if baby is not None:
record["baby"] = {key: value for key, value in baby.items() if key != TYPE_FIELD}
record.update(fields)
return record
def diaper(
baby: Mapping[str, Any],
status: DiaperStatus,
amount: DiaperAmount = DiaperAmount.NOSET,
**kwargs: Any,
) -> dict[str, Any]:
"""Build a diaper record.
``amount`` defaults to NOSET because the app's statistics count WET with
amount NONE as a dry diaper.
"""
return new_activity("Diaper", baby, status=status, amount=amount, **kwargs)
def sleep(baby: Mapping[str, Any], minutes: int, **kwargs: Any) -> dict[str, Any]:
return new_activity("Sleep", baby, duration=minutes, **kwargs)
def formula(baby: Mapping[str, Any], amount: float, english: bool = False, **kwargs: Any) -> dict[str, Any]:
return new_activity("Formula", baby, amount=measure(amount, english), **kwargs)
def pumped(baby: Mapping[str, Any], amount: float, english: bool = False, **kwargs: Any) -> dict[str, Any]:
"""A feed of expressed milk. ``Pump`` is the separate pumping session record."""
return new_activity("Pumped", baby, amount=measure(amount, english), **kwargs)
def nursing(
baby: Mapping[str, Any],
left_minutes: int = 0,
right_minutes: int = 0,
both_minutes: int = 0,
finish_side: NursingFinishSide = NursingFinishSide.NOT_SET,
**kwargs: Any,
) -> dict[str, Any]:
return new_activity(
"Nursing",
baby,
leftDuration=left_minutes,
rightDuration=right_minutes,
bothDuration=both_minutes,
finishSide=finish_side,
**kwargs,
)
def temperature(baby: Mapping[str, Any], value: float, english: bool = False, **kwargs: Any) -> dict[str, Any]:
return new_activity("Temperature", baby, temperature=measure(value, english), **kwargs)
def growth(
baby: Mapping[str, Any],
weight: Optional[float] = None,
length: Optional[float] = None,
head: Optional[float] = None,
english: bool = False,
**kwargs: Any,
) -> dict[str, Any]:
fields: dict[str, Any] = {"deleted": False, "queryIndex": 0}
if weight is not None:
fields["weight"] = measure(weight, english)
if length is not None:
fields["length"] = measure(length, english)
if head is not None:
fields["head"] = measure(head, english)
return new_activity("Growth", baby, **fields, **kwargs)
def bath(baby: Mapping[str, Any], **kwargs: Any) -> dict[str, Any]:
return new_activity("Bath", baby, **kwargs)
def joy(baby: Mapping[str, Any], **kwargs: Any) -> dict[str, Any]:
return new_activity("Joy", baby, **kwargs)
def journal(baby: Mapping[str, Any], note: str, **kwargs: Any) -> dict[str, Any]:
return new_activity("Journal", baby, note=note, **kwargs)
@@ -0,0 +1,263 @@
"""A local copy of an account's data, built from the seed and transaction logs.
The server offers no way to query records, so anything that wants to show them
keeps its own copy. Records are normalized here to metric units and readable
names. The original record of a baby is kept because new activities nest it.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any, Collection, Iterable, Mapping, Optional, Union
from .models import TYPE_FIELD, DiaperAmount, DiaperStatus, NursingFinishSide, OpCode, parse_date
from .seed import read_babies, read_records
BOTTLE_KINDS = frozenset({"Formula", "Pumped"})
FEED_KINDS = BOTTLE_KINDS | {"Nursing", "OtherFeed"}
_ML_PER_FL_OZ = 29.5735
_KG_PER_LB = 0.45359237
_CM_PER_IN = 2.54
_LABELS = {
"DoctorVisit": "Doctor visit",
"HealthQuestion": "Health question",
"OtherActivity": "Activity",
"OtherFeed": "Feed",
"Pumped": "Pumped milk",
}
def _date(value: Union[datetime, str, None]) -> Optional[datetime]:
if value is None or isinstance(value, datetime):
return value
return parse_date(value)
def _measure(measure: Optional[Mapping[str, Any]], english_factor: float) -> Optional[float]:
if not measure or measure.get("value") is None:
return None
value = float(measure["value"])
return value * english_factor if measure.get("englishMeasure") else value
def _celsius(measure: Optional[Mapping[str, Any]]) -> Optional[float]:
if not measure or measure.get("value") is None:
return None
value = float(measure["value"])
return (value - 32) * 5 / 9 if measure.get("englishMeasure") else value
def _name(nested: Optional[Mapping[str, Any]]) -> Optional[str]:
return nested.get("name") if nested else None
def _enum_name(enum: Any, value: Any) -> Optional[str]:
try:
return enum(int(value)).name.lower()
except (TypeError, ValueError):
return None
def _diaper_details(record: Mapping[str, Any]) -> dict[str, Any]:
status = _enum_name(DiaperStatus, record.get("status"))
amount = _enum_name(DiaperAmount, record.get("amount"))
# The app's statistics query counts wet with amount none as dry.
if status == "wet" and amount == "none":
status = "dry"
if amount in ("none", "noset"):
amount = None
return {"status": status, "amount": amount}
def _nursing_details(record: Mapping[str, Any]) -> dict[str, Any]:
left = int(record.get("leftDuration") or 0)
right = int(record.get("rightDuration") or 0)
both = int(record.get("bothDuration") or 0)
side = _enum_name(NursingFinishSide, record.get("finishSide"))
return {
"left_minutes": left,
"right_minutes": right,
"both_minutes": both,
"duration": left + right + both,
"finish_side": None if side == "not_set" else side,
}
def _details(kind: str, record: Mapping[str, Any]) -> dict[str, Any]:
if kind == "Diaper":
return _diaper_details(record)
if kind == "Sleep":
return {"duration": int(record.get("duration") or 0)}
if kind in ("Formula", "Pumped", "Pump"):
return {"volume_ml": _measure(record.get("amount"), _ML_PER_FL_OZ)}
if kind == "Nursing":
return _nursing_details(record)
if kind == "OtherFeed":
amount = record.get("amount") or {}
return {"name": _name(record.get("feedType")), "amount": amount.get("value"), "unit": record.get("unit")}
if kind == "Temperature":
return {"celsius": _celsius(record.get("temperature"))}
if kind == "Growth":
return {
"weight_kg": _measure(record.get("weight"), _KG_PER_LB),
"length_cm": _measure(record.get("length"), _CM_PER_IN),
"head_cm": _measure(record.get("head"), _CM_PER_IN),
}
if kind == "Medication":
return {"name": _name(record.get("medicationSelection")), "amount": record.get("amount")}
if kind == "Vaccine":
return {"name": _name(record.get("vaccineType"))}
if kind == "Milestone":
return {"name": _name(record.get("milestoneType"))}
if kind == "OtherActivity":
return {"name": _name(record.get("desc")), "duration": int(record.get("duration") or 0)}
return {}
def _describe(kind: str, details: Mapping[str, Any]) -> str:
if kind == "Diaper":
return ", ".join(filter(None, (details.get("status"), details.get("amount"))))
if kind == "Sleep":
return f"{details['duration']} min" if details.get("duration") else ""
if kind in ("Formula", "Pumped", "Pump"):
return f"{details['volume_ml']:.0f} ml" if details.get("volume_ml") else ""
if kind == "Nursing":
sides = (("left", "left_minutes"), ("right", "right_minutes"), ("both", "both_minutes"))
return ", ".join(f"{label} {details[key]} min" for label, key in sides if details.get(key))
if kind == "Temperature":
return f"{details['celsius']:.1f} °C" if details.get("celsius") is not None else ""
if kind == "Growth":
parts = (("weight_kg", "kg"), ("length_cm", "cm"), ("head_cm", "cm head"))
return ", ".join(f"{details[key]:.1f} {unit}" for key, unit in parts if details.get(key))
return details.get("name") or ""
@dataclass(frozen=True)
class Baby:
id: str
name: str
record: Mapping[str, Any] = field(repr=False)
@dataclass(frozen=True)
class Entry:
"""One logged activity, in metric units. ``time`` is when it started."""
id: str
kind: str
baby_id: Optional[str]
time: datetime
note: str
details: Mapping[str, Any]
@property
def end(self) -> datetime:
return self.time + timedelta(minutes=self.details.get("duration") or 0)
@property
def summary(self) -> str:
label = _LABELS.get(self.kind, self.kind)
description = _describe(self.kind, self.details)
return f"{label}: {description}" if description else label
class Replica:
"""The account's babies and activities. Not safe to use from several threads."""
def __init__(self) -> None:
self.babies: dict[str, Baby] = {}
self._entries: dict[str, Entry] = {}
self._newest: dict[str, Optional[datetime]] = {}
self._by_baby: Optional[dict[Optional[str], list[Entry]]] = None
@classmethod
def from_seed(cls, connection: sqlite3.Connection) -> "Replica":
replica = cls()
for baby in read_babies(connection):
replica.apply(OpCode.INSERT, {TYPE_FIELD: "Baby", **baby})
for record in read_records(connection):
replica.apply(OpCode.INSERT, record)
return replica
def apply(self, op: OpCode, record: Mapping[str, Any]) -> None:
"""Apply a record from the seed or a transaction log.
Logs from different devices arrive device by device rather than in the
order things happened, so a change older than the one held is ignored.
Records that are neither babies nor activities, such as selection lists
and pictures, are ignored too.
"""
if op == OpCode.CONFLICT:
self._apply_conflict(record)
return
kind = record.get(TYPE_FIELD)
is_baby = kind == "Baby"
if not is_baby and "time" not in record:
return
object_id = record["objectID"]
timestamp = _date(record.get("timestamp"))
newest = self._newest.get(object_id)
if timestamp and newest and timestamp < newest:
return
self._newest[object_id] = timestamp or newest
if op == OpCode.DELETE:
self.babies.pop(object_id, None)
self._entries.pop(object_id, None)
elif is_baby:
self.babies[object_id] = Baby(object_id, record.get("name") or "", dict(record))
else:
baby = record.get("baby")
self._entries[object_id] = Entry(
id=object_id,
kind=kind,
baby_id=baby.get("objectID") if baby else None,
time=_date(record["time"]),
note=record.get("note") or "",
details=_details(kind, record),
)
self._by_baby = None
def _apply_conflict(self, record: Mapping[str, Any]) -> None:
# Two devices created the same baby separately. The winner's record stays
# and the loser's activities move to it. Selection conflicts change only
# the names held inside activities, so they are left alone.
winner = record.get("winner") or {}
loser = record.get("loser") or {}
if loser.get(TYPE_FIELD) != "Baby":
return
self.apply(OpCode.UPDATE, winner)
self.babies.pop(loser["objectID"], None)
for entry in list(self._entries.values()):
if entry.baby_id == loser["objectID"]:
self._entries[entry.id] = Entry(
entry.id, entry.kind, winner["objectID"], entry.time, entry.note, entry.details
)
self._by_baby = None
def entries(self, baby_id: Optional[str], kinds: Optional[Collection[str]] = None) -> Iterable[Entry]:
"""Iterate over a baby's entries, newest first. ``None`` is entries with no baby."""
if self._by_baby is None:
grouped: dict[Optional[str], list[Entry]] = {}
for entry in self._entries.values():
grouped.setdefault(entry.baby_id, []).append(entry)
for entries in grouped.values():
entries.sort(key=lambda entry: entry.time, reverse=True)
self._by_baby = grouped
entries = self._by_baby.get(baby_id, [])
return entries if kinds is None else (entry for entry in entries if entry.kind in kinds)
def latest(self, baby_id: Optional[str], kinds: Optional[Collection[str]] = None) -> Optional[Entry]:
return next(iter(self.entries(baby_id, kinds)), None)
def since(self, baby_id: Optional[str], start: datetime, kinds: Optional[Collection[str]] = None) -> list[Entry]:
result = []
for entry in self.entries(baby_id, kinds):
if entry.time < start:
break
result.append(entry)
return result
@@ -0,0 +1,193 @@
"""Read the seed database that ``GET /account/seed`` returns.
The seed is a zip holding the app's SQLite database. It is the full account
state at some point in time; transaction logs are replayed on top of it.
"""
from __future__ import annotations
import shutil
import sqlite3
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator, Optional, Union
from .models import TYPE_FIELD, format_date, measure
SEED_DB_NAME = "EasyLog.db"
PathLike = Union[str, Path]
def extract_seed_db(zip_path: PathLike, dest_path: PathLike) -> Path:
"""Extract the database from a seed zip to ``dest_path``.
Copies only the one expected member, so paths inside the archive never
decide where anything is written.
"""
dest = Path(dest_path)
with zipfile.ZipFile(zip_path) as archive:
matches = [name for name in archive.namelist() if Path(name).name == SEED_DB_NAME]
if not matches:
raise ValueError(f"{SEED_DB_NAME} not found in seed zip")
with archive.open(matches[0]) as source, dest.open("wb") as target:
shutil.copyfileobj(source, target)
return dest
def open_seed_db(db_path: PathLike) -> sqlite3.Connection:
"""Open an extracted seed database read-only, with rows addressable by name."""
connection = sqlite3.connect(f"{Path(db_path).resolve().as_uri()}?mode=ro", uri=True)
connection.row_factory = sqlite3.Row
return connection
def _date_field(seconds: Optional[float]) -> Optional[str]:
# Database times are Unix seconds. The app treats values <= 0 as unset.
if not seconds or seconds <= 0:
return None
return format_date(datetime.fromtimestamp(seconds, timezone.utc))
def read_babies(connection: sqlite3.Connection) -> list[dict[str, Any]]:
"""Return babies as records usable in ``baby=`` of the record builders."""
babies = []
for row in connection.execute("SELECT ID, Timestamp, Name, DOB, DueDay, Gender, Picture FROM Baby"):
baby: dict[str, Any] = {
"gender": bool(row["Gender"]), # True is boy.
"name": row["Name"],
"newFlage": False,
"objectID": row["ID"],
}
if row["Picture"]:
baby["pictureName"] = row["Picture"]
for field, value in (("dob", row["DOB"]), ("dueDay", row["DueDay"]), ("timestamp", row["Timestamp"])):
formatted = _date_field(value)
if formatted:
baby[field] = formatted
babies.append(baby)
return babies
def read_cursors(connection: sqlite3.Connection) -> dict[str, int]:
"""Return per-device SyncIDs already merged into the seed.
Passing these to :meth:`BabyTrackerClient.pull` replays only the
transactions that are newer than the seed.
"""
rows = connection.execute("SELECT DeviceID, SyncID FROM MergedTransaction")
return {row["DeviceID"]: row["SyncID"] for row in rows}
# Database tables holding activities, and the record type each corresponds to.
_ACTIVITY_TABLES = {
"Allergen": "Allergen",
"Bath": "Bath",
"Diaper": "Diaper",
"DoctorVisit": "DoctorVisit",
"Formula": "Formula",
"Growth": "Growth",
"HealthQuestion": "HealthQuestion",
"Journal": "Journal",
"Joy": "Joy",
"Medicine": "Medication",
"Milestone": "Milestone",
"Nursing": "Nursing",
"OtherActivity": "OtherActivity",
"OtherFeed": "OtherFeed",
"Pump": "Pump",
"Pumped": "Pumped",
"Sick": "Sick",
"Sleep": "Sleep",
"Temperature": "Temperature",
"Vaccine": "Vaccine",
}
def _names(connection: sqlite3.Connection, table: str) -> dict[str, str]:
try:
return {row["ID"]: row["Name"] for row in connection.execute(f'SELECT ID, Name FROM "{table}"')}
except sqlite3.OperationalError:
return {}
def _optional_measure(value: Optional[float], english: Any) -> Optional[dict[str, Any]]:
# Growth columns default to 0, and the app reads 0 as "not measured".
return measure(value, bool(english)) if value else None
def _activity_fields(table: str, row: dict[str, Any], names: dict[str, dict[str, str]]) -> dict[str, Any]:
"""Convert a table's own columns to the fields its record type uses in the log."""
if table == "Diaper":
return {"status": row.get("Status"), "amount": row.get("Amount")}
if table == "Sleep":
return {"duration": row.get("Duration") or 0}
if table in ("Formula", "Pumped", "Pump"):
return {"amount": measure(row.get("Amount") or 0, bool(row.get("IsEnglishScale")))}
if table == "Nursing":
return {
"finishSide": row.get("FinishSide"),
"leftDuration": row.get("LeftDuration") or 0,
"rightDuration": row.get("RightDuration") or 0,
"bothDuration": row.get("BothDuration") or 0,
}
if table == "OtherFeed":
return {
"feedType": {"name": names["OtherFeedSelection"].get(row.get("TypeID"))},
"amount": measure(row.get("Amount") or 0, bool(row.get("IsEnglishScale"))),
"unit": row.get("Unit"),
}
if table == "Temperature":
return {"temperature": measure(row.get("Temp") or 0, bool(row.get("IsEnglishScale")))}
if table == "Growth":
# Length and head share one scale column.
return {
"weight": _optional_measure(row.get("Weight"), row.get("IsEnglishWeightScale")),
"length": _optional_measure(row.get("Length"), row.get("IsEnglishLengthScale")),
"head": _optional_measure(row.get("Head"), row.get("IsEnglishLengthScale")),
}
if table == "Medicine":
return {
"medicationSelection": {"name": names["MedicineSelection"].get(row.get("MedID"))},
"amount": row.get("Amount"),
}
if table == "Vaccine":
return {"vaccineType": {"name": names["VaccineSelection"].get(row.get("VaccID"))}}
if table == "Milestone":
return {"milestoneType": {"name": names["MilestoneSelection"].get(row.get("MilestoneSelectionID"))}}
if table == "OtherActivity":
return {
"desc": {"name": names["OtherActivityDesc"].get(row.get("DescID"))},
"duration": row.get("Duration") or 0,
}
return {}
def read_records(connection: sqlite3.Connection) -> Iterator[dict[str, Any]]:
"""Yield the seed's activities, shaped like the records in transaction logs.
Tables missing from an older or newer database are skipped.
"""
names = {
table: _names(connection, table)
for table in ("MedicineSelection", "MilestoneSelection", "OtherActivityDesc", "OtherFeedSelection", "VaccineSelection")
}
for table, record_type in _ACTIVITY_TABLES.items():
try:
rows = connection.execute(f'SELECT * FROM "{table}"').fetchall()
except sqlite3.OperationalError:
continue
for sqlite_row in rows:
row = dict(sqlite_row)
record: dict[str, Any] = {
TYPE_FIELD: record_type,
"objectID": row["ID"],
"time": datetime.fromtimestamp(row["Time"], timezone.utc),
"timestamp": _date_field(row.get("Timestamp")),
"note": row.get("Note") or "",
}
if row.get("BabyID"):
record["baby"] = {"objectID": row["BabyID"]}
record.update(_activity_fields(table, row, names))
yield record
+49
View File
@@ -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)]
)
+12
View File
@@ -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"]
@@ -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]},
)
+11
View File
@@ -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
@@ -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"]
+48
View File
@@ -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))
+70
View File
@@ -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"
}
}
}
@@ -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"
}
+194
View File
@@ -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)
+246
View File
@@ -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)
+156
View File
@@ -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
@@ -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}"
}
}
}