Skip to main content

marvin-connected-home

If you find marvin-connected-home useful, consider donating: Donate

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 token
  • StaticTokenProvider — bring your own token, for testing
  • TokenProvider — 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)

Source distribution for marvin-connected-home 0.5.0
File Size Uploaded
marvin_connected_home-0.5.0.tar.gz 48.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for marvin-connected-home 0.5.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page