Files
arneandClaude Opus 5.5 6466f002e0 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>
2026-11-04 10:04:39 +00:00

235 lines
11 KiB
Markdown

# 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": "<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
```text
\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**
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 `<fileName>.jpg` for a picture, `<fileName>.mp4` for 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.