marvin-connected-home
If you find marvin-connected-home useful, consider donating:
Async Python client for the Marvin Connected Home cloud API — automated windows, skylights, doors and privacy glass.
Unofficial. Marvin publishes no public API. Everything here was reverse-engineered from the Marvin Home Android app. Marvin has already re-platformed this service once (Google Cloud IoT Core → Azure), so it can change without notice.
Built to be platform-agnostic so it can back a Home Assistant integration (ha-marvin-connected-home) or anything else.
Why cloud and not local
The windows are ESP32 devices holding a single outbound MQTT/TLS connection to Azure IoT Hub. A full 65,535-port scan of real hardware found no listening ports — no HTTP, no local MQTT, no ESPHome-style API. Local control is not achievable without modifying firmware.
The upside: the cloud API pushes state over SignalR in sub-second time, including for changes that never went through the cloud at all. A close triggered by a dry-contact relay produced live progressive position updates and a final lock confirmation. That is markedly better than the ~10-minute polling latency Marvin's sanctioned Control4 driver is limited to.
Install
pip install marvin-connected-home
Usage
import aiohttp
from marvin_connected_home import B2CTokenProvider, MarvinClient, MarvinRealtime
async with aiohttp.ClientSession() as session:
tokens = B2CTokenProvider(
session,
refresh_token=saved_refresh_token,
# Called on every rotation. Persist what you receive here: the old
# refresh token must be assumed single-use.
on_refresh_token_update=save_refresh_token,
)
client = MarvinClient(session, tokens)
houses = await client.async_get_houses()
house = await client.async_get_house(houses[0]["id"])
for asset in house.assets:
device = asset.primary
print(f"{asset.name}: {device.sash_position}% open, locked={device.locked}")
# Positions are percentages, not discrete stops.
await client.async_set_sash_position(house.assets[0].asset_id, 45)
# Live updates
realtime = MarvinRealtime(session, tokens)
realtime.on_asset_update(lambda a: print(f"{a.name} -> {a.primary.sash_position}%"))
await realtime.async_start()
Refresh tokens rotate on every renewal (roughly hourly). The
on_refresh_token_update callback is the intended persistence path — it fires
on every rotation, so storage never holds a stale credential. Reading
tokens.refresh_token manually after each call still works but is easy to get
wrong; the callback exists because a consumer that persisted only the
sign-in-time token was one restart away from a forced re-login.
All requests carry explicit timeouts (15 s REST, 30 s token endpoint), so "the
cloud is unreachable" surfaces as a MarvinConnectionError in seconds rather
than aiohttp's five-minute default — which matters for consumers that fail over
to a local control path. Override with MarvinClient(..., request_timeout=...).
Knowing whether push is live
MarvinRealtime.connected is true while the hub connection is up, running
while the reconnect loop is alive, and on_connection_change(callback) fires
on every transition -- enough for a consumer that also polls to tighten its
interval while push is down. The loop retries everything except an
authentication failure, after which running goes false and the consumer
should call async_start() again once it has a working credential.
A 401 on a cached access token costs one refresh, not a sign-in: the client passes the rejected token back to the provider, which mints a new one (or hands over the one another caller already fetched) before retrying once.
Status
| Area | Status |
|---|---|
| Sash position (get / set) | Verified against hardware |
| Lock, rain, e-brake, battery, RSSI, firmware | Verified |
Config writes (setconfig) |
Verified — contact positions, rain-close, buzzer, LED |
| House preferences (auto-venting) | Verified — all limit and toggle keys; single- and multi-key bodies both accepted |
| Asset rename | Verified |
| Firmware update trigger | Verified |
| SignalR real-time | Verified — AssetUpdated and PreferencesUpdated modelled; GroupStateUpdated, GroupListChanged, HouseGroupStateUpdated reach on_raw_message |
| Shade, LED, lock, privacy-glass commands | Inferred from app constants — untested |
| Group commands | There are none. House-wide broadcast via a House id works; server-side groups have no command endpoint — the app fans out client-side into a batched /commands |
| Reboot / recalibrate | Verified. async_recalibrate_device drives the sash through full travel — put a confirmation in front of it |
Verification was done against Modern Automated Casement/Awning windows only. Awaken skylights, Multi-Slide doors and CLiC privacy glass are supported on a best-effort basis via capability flags — if you own that hardware, bug reports are welcome.
Things that will bite you
Preference keys are spelled differently on read and write
Reading a house gives temperatureUpperLimit. Writing that back does nothing — the
write name is tempUpperLimit. Only the four temperature keys are affected;
humidityUpperLimit and friends are identical both ways.
async_set_house_preferences accepts read names and translates them, so this only
bites if you build the request yourself. See PREFERENCE_WRITE_KEYS.
The dew-point limits (dewPointUpperLimit, dewPointLowerLimit) are readable but
their write names are unknown — the app never wrote them during capture. Given
the asymmetry above, do not guess them.
Pass both ends of a range in one call. The endpoint accepts multi-key bodies, and
splitting them leaves the house transiently holding lower > upper.
Temperatures are Fahrenheit
There is no unit field anywhere in the API. /defaults, the obvious candidate,
returns {"data": []}. Values are Fahrenheit; Marvin sells into the US and Canada
only. Verified on a single account, so treat it as well-evidenced rather than
guaranteed.
GET /houses and GET /houses/{id} are complementary
The list endpoint populates state and nulls preferences. The detail endpoint
does the reverse. Neither is a superset — awayModeIsActive is only reachable from
the list.
All of these are handled by this library. They are listed because anyone reading the raw API will hit them.
Sentinel values. Absent numerics come back as type minimums, not null — -1.7976931348623157e308 for doubles, -2147483648 for ints — and inconsistently between sibling fields (outdoorHumidity uses the int sentinel inside a float while indoorHumidity uses the double one). Normalised to None via denull(). A sentinel recorded as a temperature will permanently corrupt long-term statistics.
Reads are double-encoded JSON. The body is a JSON string whose content is the real document, so it starts with a quote, not a brace, and must be decoded twice. SignalR's arguments[0] does the same thing.
Writes return plain text, inconsistently: Ok from setconfig, OK from rename, Success from preferences, a full sentence from performota. Anything assuming a JSON body breaks on every write.
Key casing varies by endpoint. GET /assets/{id} returns WCBfirmwareVersion; SignalR returns wcBfirmwareVersion. Resolved case-insensitively here.
GET /houses/{id} returns only asset stubs ({id, name}). The full device tree lives at GET /houses/{id}/assets; async_get_house() fetches both.
AssetUpdated pushes have carried the full asset in every capture — but that is observed, not guaranteed, and the stub behaviour above proves Marvin sends partial assets in some contexts. merge_assets() merges a push over cached state field-preservingly, so a partial push can never flip cached config to unknown. Consumers holding state across pushes should use it instead of replacing wholesale.
Realtime reconnects forever with exponential backoff. Individual failures log at debug; ten consecutive failures log one warning (something structural has likely changed). An authentication failure stops the loop instead of retrying — retrying cannot mint a valid token, and the consumer's own auth path will surface the problem.
Authentication
Azure AD B2C, using the mobile app's public client id (it ships in the APK; Marvin offers no third-party registration).
| Tenant | marvinwindowsb2c.onmicrosoft.com |
| Client id | 0d117826-a605-4d81-999e-ae67e85de895 |
| Policy | B2C_1A_AuroraSignInRegister |
| Redirect | https://jwt.ms (used here); aurora://login/verify is the app's |
| Scopes | openid offline_access |
| Flow | authorization code + PKCE |
Sign in with scripts/login.py, which prints an authorize URL and then redeems the redirect you land on. Verified end to end, including renewal.
The bearer is the id_token, not an access token. Because Marvin requests no resource scope, B2C returns only id_token and refresh_token. Their app sends the id_token as its bearer — hence a token with aud=<client_id> and an emailAddress claim — so this library does the same. Worth knowing that this is unusual: id_tokens are meant to identify a user to a client, not to authorise API calls. If Marvin ever require a properly-audienced access token, this breaks.
New redirects cannot be registered against Marvin's tenant, so a consumer has to use one that already is. The app's own aurora://login/verify is a custom URI scheme no browser can open (and Safari discards outright), but probing the authorize endpoint — which validates redirect_uri before rendering any sign-in page, answering AADB2C90006 when it is unregistered — shows https://jwt.ms is also registered. That is Microsoft's token-inspection page, almost certainly a leftover from the Azure portal's "Run user flow" default, and it is what this library defaults to: a real page in every browser, so the code is just in the address bar.
The authorize URL asks for response_mode=fragment, which keeps the code out of jwt.ms's server logs — fragments are never sent to the server.
Confirmed not registered, so not available: every http://localhost and 127.0.0.1 spelling (a loopback listener would remove the paste entirely), https://<tenant>.b2clogin.com/oauth2/nativeclient, msal<client_id>://auth, urn:ietf:wg:oauth:2.0:oob, and https://my.home-assistant.io/redirect/oauth.
Token handling is pluggable:
B2CTokenProvider— the normal path; owns refresh and rotates the stored tokenStaticTokenProvider— bring your own token, for testingTokenProvider— the protocol the client depends on, so you can implement your own
There is no device-authorization grant to use. It would suit headless consumers best, and Marvin's Control4 driver appears to offer one, but Azure AD B2C does not implement RFC 8628 — the policy's OIDC discovery document advertises no device_authorization_endpoint. That driver's verification URL and user code must be Chowmain's own relay service, holding a registration nobody else can use.
Verification
Validated end to end against a live account: refresh-token renewal, the full
read surface, config writes, house preferences, asset rename, OTA trigger,
and SignalR push. scripts/smoke_test.py runs that suite read-only against
your own account and touches no device.
Development
python -m venv .venv && ./.venv/bin/pip install -e ".[dev]"
./.venv/bin/python -m pytest
Never commit .mitm capture files — they contain live bearer tokens. .gitignore covers them.
Licence
MIT
Metadata
Release files for marvin-connected-home 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| marvin_connected_home-0.5.0.tar.gz | 48.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| marvin_connected_home-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 84.6 kB
Release files / marvin_connected_home-0.5.0.tar.gz
| Download URL | marvin_connected_home-0.5.0.tar.gz |
|---|---|
| Size | 48.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eab8c9c5c7f00f38cc49b6e4ac9dadb400bb81288d86cb534e58bde8647ebe9a
|
|
BLAKE2b-256 checksum How to use checksums |
7cd9375e2c15d0b1d1cdb0a92a1b5425ca75135fbfd694ab440b9f910506dd9c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 4, 2026.
Transparency logRelease files / marvin_connected_home-0.5.0-py3-none-any.whl
| Download URL | marvin_connected_home-0.5.0-py3-none-any.whl |
|---|---|
| Size | 35.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6ebee4b6d5803436e79ff07f96c654eafd6ec26bd637deec860c61c7f61f17ae
|
|
BLAKE2b-256 checksum How to use checksums |
1416b5a679ef2cb07caa7b30f49a841ec883784a6efaa0a65c15dcb14846f21a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 4, 2026.
Transparency log