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