Metadata-Version: 2.4
Name: marvin-connected-home
Version: 0.4.0
Summary: Async client for the Marvin Connected Home cloud API (unofficial)
Project-URL: Homepage, https://github.com/cb2206/marvin-connected-home
Project-URL: Documentation, https://cb2206.github.io/ha-marvin-connected-home/
Project-URL: Home Assistant integration, https://github.com/cb2206/ha-marvin-connected-home
Project-URL: Issues, https://github.com/cb2206/marvin-connected-home/issues
Author: Christian Bartels
License: MIT
License-File: LICENSE
Keywords: connected-home,marvin,skylight,smart-home,windows
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Home Automation
Requires-Python: >=3.11
Requires-Dist: aiohttp>=3.9
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# marvin-connected-home

Async Python client for the [Marvin Connected Home](https://www.marvin.com/solutions/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](https://github.com/cb2206/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

```bash
pip install marvin-connected-home
```

## Usage

```python
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 | `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

```bash
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
