marvin-connected-home
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]["data"][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=...).
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 | aurora://login/verify |
| 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.
The redirect is a custom URI scheme, so no web server can receive it and new redirects cannot be registered against Marvin's tenant. Desktop and headless consumers have to collect the code by hand — Chrome keeps the failed aurora:// URL in the address bar; Safari discards it, in which case read the Location header of the final 302 from DevTools with Preserve log enabled.
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
If Marvin expose the device-authorization pairing their Control4 driver hints at, that would suit headless consumers better and belongs here as a third provider.
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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file marvin_connected_home-0.3.0.tar.gz.
File metadata
- Download URL: marvin_connected_home-0.3.0.tar.gz
- Upload date:
- Size: 37.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
25d7dbd420554d08370e975fb8d47eda5d83d7798adc935b88268315a17ebc7a
|
|
| MD5 |
6c79f5ed2ce01d8bff9388664d0936eb
|
|
| BLAKE2b-256 |
9b3fd125d209c60fe08689f92c11291b76103037aab8a6ab4930bac5736ff621
|
Provenance
The following attestation bundles were made for marvin_connected_home-0.3.0.tar.gz:
Publisher:
release.yml on cb2206/marvin-connected-home
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
marvin_connected_home-0.3.0.tar.gz -
Subject digest:
25d7dbd420554d08370e975fb8d47eda5d83d7798adc935b88268315a17ebc7a - Sigstore transparency entry: 2296807403
- Sigstore integration time:
-
Permalink:
cb2206/marvin-connected-home@b1ba4792d90e6abf7086e3b286a9754d41cc1119 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/cb2206
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b1ba4792d90e6abf7086e3b286a9754d41cc1119 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file marvin_connected_home-0.3.0-py3-none-any.whl.
File metadata
- Download URL: marvin_connected_home-0.3.0-py3-none-any.whl
- Upload date:
- Size: 30.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a1a48aa65a7d5c1a262ef3418c1fb5488ce53ecb065605b58ef8a3bc0f08d1c8
|
|
| MD5 |
f5e67a63303fdda9091e2e20e8620253
|
|
| BLAKE2b-256 |
87dc05c32440748a5cf2ff0819b659db853e3f2128cfdbb4a583de9cae1a27ca
|
Provenance
The following attestation bundles were made for marvin_connected_home-0.3.0-py3-none-any.whl:
Publisher:
release.yml on cb2206/marvin-connected-home
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
marvin_connected_home-0.3.0-py3-none-any.whl -
Subject digest:
a1a48aa65a7d5c1a262ef3418c1fb5488ce53ecb065605b58ef8a3bc0f08d1c8 - Sigstore transparency entry: 2296807493
- Sigstore integration time:
-
Permalink:
cb2206/marvin-connected-home@b1ba4792d90e6abf7086e3b286a9754d41cc1119 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/cb2206
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b1ba4792d90e6abf7086e3b286a9754d41cc1119 -
Trigger Event:
workflow_dispatch
-
Statement type: