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

179 lines
6.6 KiB
Python

"""In-process fake of the sync server, written from the app's client code.
It checks that this client speaks the protocol as the decompiled app does. It
cannot show that the real server behaves as the app expects.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Optional
COOKIE = "session=abc123"
class MockState:
def __init__(self) -> None:
self.account_id = 42
self.email = "parent@example.com"
self.password = "correct-horse"
self.valid_sessions = True
self.login_status: Optional[int] = None
self.devices: list[dict[str, Any]] = []
self.logs: dict[str, list[dict[str, Any]]] = {}
self.uploads: list[dict[str, Any]] = []
self.files: dict[str, bytes] = {}
self.seed: Optional[bytes] = None
self.requests: list[tuple[str, str]] = []
self.login_bodies: list[dict[str, Any]] = []
self.multipart: dict[str, tuple[str, bytes]] = {}
# Make uploads appear in the uploader's log, as a real server would.
self.log_uploads = False
self.fail_with: Optional[int] = None
class Handler(BaseHTTPRequestHandler):
server: "MockServer"
def log_message(self, *args: Any) -> None:
pass
def _reply(self, status: int, body: Any = None, headers: Optional[dict[str, str]] = None) -> None:
payload = b""
if isinstance(body, bytes):
payload = body
elif body is not None:
payload = json.dumps(body).encode("utf-8")
self.send_response(status)
for key, value in (headers or {}).items():
self.send_header(key, value)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _body(self) -> bytes:
return self.rfile.read(int(self.headers.get("Content-Length") or 0))
def _authorized(self) -> bool:
state = self.server.state
if state.valid_sessions and COOKIE in (self.headers.get("Cookie") or ""):
return True
self._reply(401, b"unauthorized")
return False
def _login(self, body: bytes) -> None:
state = self.server.state
data = json.loads(body)
state.login_bodies.append(data)
if state.login_status:
self._reply(state.login_status, b"nope", {"Location": "/redirected"})
return
if data["EmailAddress"] != state.email or data["Password"] != state.password:
self._reply(401, b"bad credentials")
return
state.valid_sessions = True
self._reply(200, {"AccountID": state.account_id}, {"Set-Cookie": f"{COOKIE}; Path=/"})
def do_POST(self) -> None:
state = self.server.state
state.requests.append(("POST", self.path))
body = self._body()
if state.fail_with and self.path != "/session":
self._reply(state.fail_with)
return
if self.path == "/session":
self._login(body)
elif self.path == "/redirected":
self._reply(200, {"AccountID": 666})
elif self.path == "/account/forgot_password":
self._reply(200)
elif self.path in ("/staging_account", "/account_reset"):
state.login_bodies.append(json.loads(body))
if json.loads(body)["EmailAddress"] == "taken@example.com":
self._reply(409, b"exists")
else:
self._reply(200, None, {"Set-Cookie": f"{COOKIE}; Path=/"})
elif not self._authorized():
return
elif self.path == "/device_token":
state.multipart["token"] = ("", body)
self._reply(201)
elif self.path == "/account/transaction":
data = json.loads(body)
state.uploads.append(data)
if state.log_uploads:
uuid = state.login_bodies[-1]["Device"]["DeviceUUID"]
state.logs.setdefault(uuid, []).append(data)
state.devices = [d for d in state.devices if d["DeviceUUID"] != uuid]
state.devices.append({"DeviceUUID": uuid, "LastSyncID": data["SyncID"]})
self._reply(201)
elif self.path in ("/account/picture", "/staging_account/picture", "/staging_account/seed", "/account/clonedata"):
state.multipart[self.path] = (self.headers.get("Content-Type", ""), body)
self._reply(201)
else:
self._reply(404)
def do_PUT(self) -> None:
state = self.server.state
state.requests.append(("PUT", self.path))
self._body()
if self.path != "/staging_account":
self._reply(404)
elif "/staging_account/seed" not in state.multipart:
self._reply(424, b"no seed")
elif self._authorized():
self._reply(200, {"AccountID": state.account_id})
def do_DELETE(self) -> None:
self.server.state.requests.append(("DELETE", self.path))
if self._authorized():
self._reply(200)
def do_GET(self) -> None:
state = self.server.state
state.requests.append(("GET", self.path))
if state.fail_with:
self._reply(state.fail_with)
return
if not self._authorized():
return
parts = self.path.strip("/").split("/")
if self.path == "/account/device":
self._reply(200, state.devices)
elif parts[:2] == ["account", "transaction"] and len(parts) == 4:
log = state.logs.get(parts[2], [])
after = int(parts[3])
self._reply(200, [item for item in log if item["SyncID"] > after][:500])
elif self.path == "/account/seed":
if state.seed is None:
self._reply(404, b"no seed")
else:
self._reply(200, state.seed, {"Content-Disposition": "attachment; filename=seed.zip"})
elif parts[:2] == ["account", "picture"] and parts[2] in state.files:
self._reply(200, state.files[parts[2]], {"Content-Disposition": f"attachment; filename={parts[2]}"})
else:
self._reply(404)
class MockServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self) -> None:
super().__init__(("127.0.0.1", 0), Handler)
self.state = MockState()
self._thread = threading.Thread(target=self.serve_forever, args=(0.01,), daemon=True)
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.server_address[1]}"
def __enter__(self) -> "MockServer":
self._thread.start()
return self
def __exit__(self, *args: Any) -> None:
self.shutdown()
self.server_close()