Metadata-Version: 2.5
Name: pynortecgo
Version: 0.9.1
Summary: Unofficial async client for the Monta API used by the Nortec Go app
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Home Automation
Classifier: Typing :: Typed
Requires-Python: >=3.13
Requires-Dist: aiohttp>=3.9
Requires-Dist: tenacity>=8.2
Description-Content-Type: text/markdown

# pynortecgo

Unofficial async Python client for the Monta API used by the **Nortec Go** app (a white-label Monta app).
Built to power a Home Assistant integration, so a Nortec Go charger can be driven by tools such as
[ev_smart_charging](https://github.com/jonasbkarlsson/ev_smart_charging).

> **Unofficial.** Not affiliated with, endorsed by or supported by Nortec or Monta. It uses the same private
> API as the app, which can change without notice.

## Install

```bash
pip install pynortecgo
```

Requires Python 3.13+.

## Usage

```python
import asyncio

import aiohttp
from pynortecgo import NortecGoClient


async def main() -> None:
    async with aiohttp.ClientSession() as session:
        client = NortecGoClient(session)
        tokens = await client.login("you@example.com", "password")  # store tokens and client.device_id
        charger = await client.get_charger()
        vehicle = await client.get_vehicle()
        forecast = await client.get_price_forecast()
        print(
            charger.state,
            charger.is_connected,
            charger.active_charge.state if charger.active_charge else None,
            vehicle.battery_level,
            forecast.slots[0].price,
            forecast.slots[0].spot_price,
        )


asyncio.run(main())
```

- **Log in once.** Login is rate-limited. Store the returned `Tokens` and `client.device_id`, and pass them
  to the next client: `client = NortecGoClient(session, tokens=tokens, device_id=device_id,
  on_tokens_refreshed=save)` followed by `client.set_charger(charger_id)`. The client refreshes the token on
  a 401 and calls `on_tokens_refreshed`. `on_tokens_refreshed` must be an async function that takes the new
  `Tokens`, e.g. `async def save(tokens: Tokens) -> None`. `on_tokens_refreshed` must not call the client.
  Store `charger.id` from the first `get_charger()` too, and call `client.set_charger(charger_id)` on later
  clients: the charger is then read directly, without finding it again.
- **The session is yours.** The client never closes it, so Home Assistant can share its own.
- **One charger and one car.** `get_vehicle()` raises if the account has no car or more than one, and so
  does `get_charger()` when no charger is set. A charger set with `set_charger()` is never looked up again:
  if the API answers 404 for it, `get_charger()` raises `ChargerNotFoundError`.
- **Car data can be old.** It comes from the car's own integration and can lag by hours; check
  `Vehicle.last_seen`. `Charger.is_connected` comes from the charger and is live. For whether the car is
  charging, read `Charger.active_charge.state`, which comes from the charger's open charge; the car's own
  `power_delivery_state` can stay "stopped" during a charge. `ChargeState.CHARGING` is assumed to mean the
  car is drawing power; whether the charge leaves that state when the car stops at its own limit hasn't
  been seen yet.
- **Charging states.** `Charger.active_charge` is the open charge (`ActiveCharge`), `None` when no
  charge is open; its `state` is a `ChargeState`, and its `id` and `can_stop` belong to that charge.
  `ChargerState.BUSY_CHARGING` alone doesn't mean power is flowing.
- **Starting and stopping.** `start_charge()` places a real card hold and starts a real charge on the
  account's one charger, paid with the account's one saved card. It refuses when a charge is in progress, the
  charger reports a state the client doesn't know, no cable is connected, or the charger still holds the last
  charge (unplug and replug the cable first). Its requests are never retried, and **you must not retry it
  automatically either**: each attempt can place a new card hold. A failure after the first payment request
  raises `ChargeStartError`, whose `step` and `hold_may_be_placed` say how far it got. `stop_charge()` stops
  the charge in progress. Both return the charge as the API answered, before it has finished starting or
  stopping, so read the charger to follow it.
- **Prices** are 15-minute slots from the current hour to about 7 days ahead, for the price zone (bidding
  area) of the account's one charger. Each slot's `price` is the total per kWh: spot price incl. VAT, the
  fixed fees and the grid tariff. `spot_price` is the spot price alone. The grid tariff is exact for the
  hours the charger prices itself (about the next 13) and estimated after that (`tariff_estimated`).
  Estimates use the tariffs the client has already seen for the same weekday and hour (or a similar day),
  so they improve as the client keeps running and start over with a new client; they can change between
  calls. Turn on debug logging for `pynortecgo` to see estimates that turned out wrong. `area_id` and
  `area_name` name the zone (`2`, DK East, is the only one observed so far) and `currency` is the prices'
  ISO 4217 code (e.g. `"DKK"`), or `None` if the API doesn't give one. The forecast reads the charger on
  every call, so it can raise the charger errors.
- **Errors** are `NortecGoError` subclasses: `AuthError`, `RateLimitError`, `NortecGoConnectionError`,
  `ApiError`, `UnexpectedResponseError` (the private API changed), `ChargerNotFoundError`,
  `MultipleChargersError`, `VehicleNotFoundError`, `MultipleVehiclesError`, `ChargeAlreadyActiveError`,
  `CableNotConnectedError`, `PaymentSourceNotFoundError`, `MultiplePaymentSourcesError`, `ChargeStartError`,
  `NoActiveChargeError`, `ChargeNotStoppableError`, `ChargerNotReleasedError`, `UnknownChargerStateError`. A
  rejected token refresh raises `AuthError`: log in again.
- **Retries** on rate limits, network errors and 502/503/504 are built in; tune them with the `retry=`
  argument (`RetryPolicy`). Starting and stopping a charge is the exception: those requests are sent once.

## Client status

Alpha.

| Feature | Status |
|---|---|
| Login, token refresh | done |
| Set a known charger (`set_charger()`) | done |
| Charger: state, cable connected | done |
| Charger: charging or not | done |
| Car: battery, plugged in, last seen | done |
| Price forecast in 15-minute slots | done |
| Start / stop charging | done |
| Open charge: energy delivered so far (`Charger.active_charge.kwh`) | done |
| Open charge: latest measured power (`Charger.active_charge.kw`; one extra request while a charge is open) | done |
| Open charge: cost so far (`Charger.active_charge.cost`, `Charger.currency`) | done |
| Open charge: start time (`Charger.active_charge.started_at`) | done |
| Last completed charge: ID, billed cost, energy, start and end time (`Charger.last_charge`; one extra request) | done |

## Development

```bash
uv sync && uv run pytest -q
```

## License

MIT
