Skip to main content

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 VerifiedAssetUpdated 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

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

marvin_connected_home-0.4.0.tar.gz (39.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

marvin_connected_home-0.4.0-py3-none-any.whl (31.6 kB view details)

Uploaded Python 3

File details

Details for the file marvin_connected_home-0.4.0.tar.gz.

File metadata

  • Download URL: marvin_connected_home-0.4.0.tar.gz
  • Upload date:
  • Size: 39.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for marvin_connected_home-0.4.0.tar.gz
Algorithm Hash digest
SHA256 9d8b9e75c8ea0c7e9911835de6eabab311064830a3d32981f75ce69cd36a2138
MD5 248d0c2f9ac2414f8949e8b878542ebe
BLAKE2b-256 2b6a9d151331c7eeebe9a80a99fb6d5fbf1e9e1fae7f4c06024ff273abc03f97

See more details on using hashes here.

Provenance

The following attestation bundles were made for marvin_connected_home-0.4.0.tar.gz:

Publisher: release.yml on cb2206/marvin-connected-home

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file marvin_connected_home-0.4.0-py3-none-any.whl.

File metadata

File hashes

Hashes for marvin_connected_home-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1f3fbaf6422a10f2647e6fe0b53ac999a5b493b111c88723f88642c47a2a0d5f
MD5 bbc8dc2800d1951c789a82826b827196
BLAKE2b-256 81136a969836867a888848b7ca90b91575be4a7ddef9d66f89ac2ae1dc3b06be

See more details on using hashes here.

Provenance

The following attestation bundles were made for marvin_connected_home-0.4.0-py3-none-any.whl:

Publisher: release.yml on cb2206/marvin-connected-home

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page