SolisCloud API client and Home Assistant integration for photovoltaic, battery, load and grid monitoring.
src/soliscloud_api/— async API client. No Home Assistant imports, usable on its own.custom_components/soliscloud/— thin Home Assistant layer on top of it.
Requires Python 3.14+ and uv.
uv sync # runtime deps
uv sync --all-groups # plus dev and test
cp .env.example .env # then fill in your credentialsSolis issues three values when you request API access:
SOLIS_ID=<key id>
SOLIS_SECRET=<key secret>
SOLIS_URL=https://www.soliscloud.com:13333/uv run ha-solis # summary of each inverter
uv run ha-solis probe # diagnose credentials, clock skew and signing
uv run ha-solis probe --hammer # also exercise the rate limiter
uv run ha-solis dump # write raw API responses to data/raw/probe is the first thing to run when something is wrong. On failure it prints the HTTP
status, the API's own code and message, and the exact string that was signed.
from soliscloud_api import SolisCloudClient
async with SolisCloudClient() as client:
listing = await client.inverter_list()
detail = await client.inverter_detail(inverter_sn=listing.records[0].sn)
print(detail.pac, "W", detail.e_total, "kWh")Or add it by hand: HACS → ⋮ → Custom repositories → https://github.com/dalectric/ha-solis,
category Integration. Then download SolisCloud and restart Home Assistant.
HACS installs the archive attached to each release, which is built by CI from this repo and already contains the API client. Updates arrive through HACS.
uv run python scripts/build_ha.py
cp -r dist/soliscloud <ha-config>/custom_components/Restart Home Assistant, then add SolisCloud from Settings → Devices & Services and enter the API URL, Key ID and Key Secret. The button above jumps straight to that dialog once the integration is installed. Polls every 5 minutes by default, adjustable in the integration's options.
Verified against Home Assistant 2026.9.2.
One config entry covers the whole account. Every inverter the API returns becomes its
own device, named Solis <serial>, each carrying the full set of ~45 entities.
Entity IDs are namespaced by serial, so nothing collides.
Two things to know:
- Entities are created at setup. Adding an inverter to your Solis account later needs an integration reload before it appears.
- Polling cost scales with inverter count. Each poll is one
inverterListplus oneinverterDetailper inverter, and the API is slow (~16s median). Three inverters is roughly a minute per poll — comfortable at a 5 minute interval; ten would be tight, so raise the interval in options.
The build step vendors the client into the component because this repo has no published
package yet. Once it is on GitHub, that can become a single manifest entry:
"requirements": ["ha-solis @ git+https://github.com/dalectric/ha-solis@main"].
SolisCloud is not internally consistent about signs. Measured on a live inverter:
pac=+1.369 kW while generating, psum=+1.156 kW while exporting and
batteryPower=-0.037 kW while charging all follow the generator convention, but
familyLoadPower=+0.19 kW while consuming follows the load convention.
This integration normalises every signed power sensor onto one sign convention, selectable at runtime via the Sign convention entity or in the integration's options:
| Generator convention (default) | Load convention | |
|---|---|---|
| also known as | active sign convention | passive sign convention |
| positive means | the component supplies the house | the component draws from the house |
| PV generating | + |
− |
| Battery | + discharging, − charging |
+ charging, − discharging |
| Grid | + importing, − exporting |
+ exporting, − importing |
| House load | − |
+ |
Every component is treated as a supplier to the house, so under the generator convention a positive reading always means "this is feeding the system" and a negative one means "this is drawing from it".
The raw signs were established by measurement, not assumption. With a 3.3 kW immersion
running: house load 3111 W, PV 550 W, grid 0 W and batteryPower −2771 W — the battery
was the only possible source, so a raw negative batteryPower is the battery
discharging. Confirmed again while charging, with storageBatteryCurrent +5.7 A and
PV 533 W against a 197 W house load.
The two are exact negations (p′ = −p). Generator convention is the default because it
matches the API's native signs for PV, grid and battery — or as the reference puts it,
"No manufacturer sells a '−5 kilowatt generator.'"
The API reports grid import and export as two separate one-way counters, so neither
alone answers "am I ahead?". Grid exchange sensors carry the balance — export minus
import — for today, this month, this year and total, alongside Grid exchange power
for the instantaneous figure.
These are genuine signed quantities, so they follow the sign convention: positive is a
net export under the generator convention, a net import under the load convention. They
use state_class: total rather than total_increasing, because a balance legitimately
falls whenever more is drawn than delivered — total_increasing would make Home
Assistant read every dip as a meter reset.
A balance is only published when both sides are reported; netting a present value
against a missing one would quietly understate it, so the sensor stays unknown.
Cumulative energy counters are deliberately left positive under both. They are not
signed quantities but separate one-way meters (kWh imported, kWh exported), and Home
Assistant's Energy dashboard requires total_increasing sensors to stay positive and
monotonic.
Energy is rendered to one decimal for daily figures and whole kWh for month, year and lifetime totals — a tenth matters on a day's output, but is noise on a four-digit total that the API only reports to three significant figures anyway.
This is display only: Home Assistant keeps the full value for long-term statistics, and you can override it per entity in the UI.
The selector's attributes spell out what positive currently means for grid, battery and PV, so the active convention is readable from a template or dashboard.
Behaviour measured against the live API, not assumed:
| Observation | Consequence |
|---|---|
| Median response ~16s, tail past 30s, and roughly 1 call in 4 exceeds 30s | Default timeout is 60s. A 10s timeout — as used by fboundy/solis-sensor — times out on most polls and reports it as "No inverters found". |
| Transient HTTP 502s | Up to 3 attempts with exponential backoff and jitter. Auth errors are never retried. |
| Documented limit of 3 calls per 5 seconds per IP | A sliding-window limiter shared across all calls on a client. |
Units differ between fields in the same response — eToday in kWh but eTotal in MWh |
Values are normalised via their <field>Str companion to W and kWh, so counters nest correctly. |
A real inverterDetail response has ~812 keys |
~50 are explicitly typed; the rest are retained via model_extra, so nothing is discarded and no schema change is needed when Solis adds a field. |
| Fields are omitted rather than zeroed when not applicable | Numerics default to None, never 0.0, so Home Assistant shows unknown instead of a false zero. |
Each failure cause raises its own exception — SolisAuthError, SolisClockSkewError,
SolisRateLimitError, SolisAPIError, SolisTransportError — so a wrong key never
looks like an empty account.
uv sync --group ha # optional: installs Home Assistant for integration tests
uv run pytest # offline suite
uv run pytest -m live # against the real API; needs .env (slow, ~5 min)
uv run ruff check .
uv run ruff format .tests/test_ha_integration.py builds the component and loads it against a real Home
Assistant install. It is skipped unless the ha group is installed.
Install the git hooks once per clone:
uv run pre-commit installTwo layers stop credentials reaching the repo, both verified against the real .env:
- gitleaks scans staged content for API keys, tokens and credentials.
.gitleaks.tomlextends the default rule set with a SolisCloud-specific rule forSOLIS_IDandSOLIS_SECRET. forbid-env-filesrejects any staged.envoutright, sincegit add -fbypasses.gitignore..env.exampleis deliberately not matched.
To check the whole history rather than just staged changes:
uv run pre-commit run --all-files