Skip to content

Architecture

Marcin edited this page May 23, 2026 · 1 revision

Architecture

Developer reference for the integration's code structure, API details and data flow.


Directory structure

custom_components/sunsynk/
├── __init__.py          — Entry setup/teardown, dashboard registration
├── manifest.json        — Integration metadata (version, dependencies)
├── config_flow.py       — Config Flow UI (initial setup + options)
├── coordinator.py       — SunsynkCoordinator + SolarForecastCoordinator
├── const.py             — Constants, sensor entity descriptions
├── sensor.py            — Sensor platform (static + dynamic + forecast sensors)
├── number.py            — Number platform (writable numeric settings)
├── switch.py            — Switch platform (writable boolean settings)
├── text.py              — Text platform (writable time slot times)
├── helpers.py           — build_device_info()
├── dashboard.py         — build_dashboard() — generates Lovelace config dict
├── strings.json         — UI strings (used by HA at dev time)
├── translations/
│   └── en.json          — UI strings (English, used at runtime)
├── api/
│   ├── __init__.py
│   ├── auth.py          — RSA login + OAuth2 token management
│   └── client.py        — All API endpoint calls
└── www/
    └── sunsynk-power-flow-card.js   — Bundled frontend card

Data flow

HA startup
  └── async_setup_entry()
        ├── Create SunsynkAuth (credentials only, no network call yet)
        ├── Create SunsynkCoordinator
        ├── coordinator.async_config_entry_first_refresh()
        │     └── _async_update_data()
        │           ├── auth.async_get_token()      — RSA login → OAuth2 token
        │           └── client.async_fetch_all()    — 8 parallel GET requests
        ├── (optional) Create SolarForecastCoordinator
        │     └── _async_update_data()
        │           └── GET api.open-meteo.com/v1/forecast
        ├── async_forward_entry_setups() — sensor / number / switch / text
        │     └── each platform's async_setup_entry()
        │           └── async_add_entities()
        └── async_create_task(_async_setup_dashboard())
              └── build_dashboard() + Lovelace storage write

Every {refresh_interval} seconds:
  └── SunsynkCoordinator._async_update_data()
        ├── auth.async_get_token()    — cached; re-fetched only on expiry
        └── client.async_fetch_all() — 8 parallel API calls
              → coordinator.data updated
              → all CoordinatorEntity sensors/numbers/switches notified
              → _add_dynamic_sensors() callback fires
                    → registers any newly-discovered MPPT/phase/battery sensors

Every 30 minutes:
  └── SolarForecastCoordinator._async_update_data()
        └── GET api.open-meteo.com/v1/forecast
              → _parse() calculates kWh totals and current-hour values
              → SolarForecastSensor entities updated

Authentication (api/auth.py)

The Sunsynk API uses a two-step authentication flow:

  1. RSA login:

    • The client fetches the server's RSA public key from /api/v1/auth/authenticate (GET)
    • The password is encrypted with this public key using RSA-OAEP/SHA-256
    • The encrypted payload is POSTed back to the same endpoint
    • Response contains an OAuth2 access_token and expires_in
  2. Token caching:

    • The token and expiry time are stored in SunsynkAuth._token and _token_expiry
    • On each coordinator refresh, async_get_token() checks if the token has expired (with a 60-second buffer) and re-fetches only when needed
    • A fresh aiohttp.ClientSession is created once per coordinator instance and reused

SunsynkAuth is shared between the main coordinator and the write operations in coordinator.async_write_setting().


API client (api/client.py)

SunsynkClient wraps all API calls. async_fetch_all() fires all 8 endpoint calls concurrently using asyncio.gather():

Method Endpoint Key in coordinator.data[serial]
async_get_inverter GET /api/v1/inverter/{sn} inverter
async_get_pv GET /api/v1/inverter/{sn}/realtime/input pv
async_get_grid GET /api/v1/inverter/grid/{sn}/realtime grid
async_get_battery GET /api/v1/inverter/battery/{sn}/realtime battery
async_get_load GET /api/v1/inverter/load/{sn}/realtime load
async_get_output GET /api/v1/inverter/{sn}/realtime/output output
async_get_temp GET /api/v1/inverter/{sn}/output/day temp
async_get_settings GET /api/v1/common/setting/{sn}/read settings

Writing a setting: POST /api/v1/common/setting/{sn}/set with the full settings payload (Sunsynk requires the entire setting group to be written together, not individual keys).


Coordinator (coordinator.py)

SunsynkCoordinator

Extends DataUpdateCoordinator[dict[str, dict[str, Any]]]. The data dict is:

{
  "SN123456": {
    "inverter": { ... },   # from async_get_inverter
    "pv":       { ... },   # from async_get_pv
    "grid":     { ... },   # from async_get_grid
    "battery":  { ... },   # from async_get_battery
    "load":     { ... },   # from async_get_load
    "output":   { ... },   # from async_get_output
    "temp":     { ... },   # from async_get_temp
    "settings": { ... },   # from async_get_settings
  },
  "SN789012": { ... },
}

If a single serial's fetch fails, the coordinator keeps the previous data for that serial and logs an error — other serials continue updating normally.

async_write_setting(serial, key, value):

  • Reads current settings from coordinator.data (falls back to a fresh API fetch if unavailable)
  • Determines which setting group the key belongs to (BATTERY_SETTING_KEYS or SYSTEM_MODE_SETTING_KEYS)
  • Writes the full group payload to the API (Sunsynk requires the whole group)
  • Calls async_request_refresh() to update state

SolarForecastCoordinator

Extends DataUpdateCoordinator[dict[str, Any]]. Fetches a single Open-Meteo URL and returns:

{
  "today_kwh":    12.4,   # estimated kWh for today
  "tomorrow_kwh": 8.7,    # estimated kWh for tomorrow
  "cloud_cover":  35.0,   # % for current hour
  "precipitation": 0.0,   # mm for current hour
  "ghi":          520.0,  # W/m² for current hour
  "dni":          680.0,  # W/m² for current hour
}

The kWh calculation iterates over all hourly time slots, filters by date (today / tomorrow), and sums GHI_wh_m2 / 1000 * panel_kwp * performance_ratio. The current-hour values are found by matching the local wall-clock hour to the ISO timestamp list from the API.


Sensor platform (sensor.py)

Static sensors

ALL_STATIC_SENSORS is a tuple of SunsynkSensorEntityDescription instances defined in const.py. Each description has:

  • key — unique identifier, used as part of unique_id
  • endpoint — which key in coordinator.data[serial] to read from
  • data_key — dot-notation path within that endpoint dict (e.g. "pvIV.0.ppv")

SunsynkSensor.native_value resolves the dot-notation path via _resolve_value().

Dynamic sensors

_build_dynamic_descriptions() is called after each coordinator update via a listener callback. It inspects the live data to determine how many MPPT strings, phases and battery slots exist, then creates and registers new SunsynkSensor entities for any that haven't been seen before.

Forecast sensors

SolarForecastSensor is a separate CoordinatorEntity backed by SolarForecastCoordinator. The forecast_key field in ForecastSensorDescription maps directly to a key in the coordinator's data dict.


Dashboard generation (dashboard.py)

build_dashboard(prefix, eid, forecast_eid) returns a pure Python dict representing a complete Lovelace dashboard configuration. No YAML parsing — it produces the dict directly so it can be serialised by HA's Lovelace storage system.

  • prefix — slugified device alias, used as fallback when entity registry lookup fails
  • eid(key) — looks up the actual entity_id from HA's entity registry by unique_id suffix; returns None for optional entities that aren't registered
  • forecast_eid(key) — same lookup for forecast entities; None if forecast is not configured

The has_forecast flag controls whether forecast cards are included. This prevents dead/unavailable entity references in the dashboard when forecast is not set up.

__init__.py's _async_setup_dashboard() is called as a background task after platform setup completes, ensuring all entities are already registered in the entity registry before the dashboard is generated.


hass.data layout

hass.data[DOMAIN] = {
    "{entry_id}":           SunsynkCoordinator,
    "{entry_id}_forecast":  SolarForecastCoordinator,  # only if configured
}

Adding a new sensor

  1. Add a SunsynkSensorEntityDescription entry to the appropriate tuple in const.py (e.g. BATTERY_SENSORS)
  2. Set endpoint to the API response key and data_key to the dot-notation path within that response
  3. If the value comes from a nested list, use dot-notation like "pvIV.0.ppv"_resolve_value() handles list indexing automatically

No other changes are needed. The sensor is automatically picked up by ALL_STATIC_SENSORS and registered on next startup.

Adding a new writable setting

  1. Add a NumberEntityDescription or SwitchEntityDescription to number.py or switch.py
  2. Set the key to match the exact Sunsynk API setting field name (e.g. "chargeCurrent")
  3. If the setting belongs to a named group (battery or system mode), add its key to BATTERY_SETTING_KEYS or SYSTEM_MODE_SETTING_KEYS in const.py — this ensures the entire group is written together

Clone this wiki locally