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>
11 KiB
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 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/jsonandcharset: utf-8. - On a 401, the app logs in again and retries once.
- After a re-login, the app compares
AccountIDwith 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
// UserInfo
{
"EmailAddress": "you@example.com",
"Password": "plain text, protected only by TLS",
"Device": {
"DeviceUUID": "random UUID",
"DeviceName": "free text",
"DeviceOSInfo": "<Build.MODEL> OS <SDK_INT>"
},
"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
\r\n--<boundary>\r\n
Content-Disposition: form-data; name="picture"; filename="abc.jpg"\r\n
Content-Type: image/jpeg\r\n\r\n
<bytes>\r\n--<boundary>--\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
GET /account/device.- For each device other than this one, compare
LastSyncIDwith the locally merged value (tableMergedTransaction). If behind, or unknown, start from the local value or from 0. GET /account/transaction/{device}/{syncID}and merge. Repeat from the lastSyncIDwhile a page has at least 500 entries.- Upload this device's pending entries oldest first, one
POSTeach, and delete each after its 201.
Join a group
- Reset the local device ID.
POST /session. A 400 or 401 means the group does not exist or the password is wrong.- Upload the old local database as clone data. The app continues if this fails.
GET /account/seed, unzip it, and replace the local database withEasyLog.db.- 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 asbabydo 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 asnachm.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
Picturerecord's file is<fileName>.jpgfor a picture,<fileName>.mp4for a video and plain<fileName>for audio. Its thumbnail is<fileName>__thumbnail.jpg. - A baby's picture is named
<pictureName>.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.