A lightweight Node.js service that fetches real-time solar inverter and battery data from SolisCloud by calling the same internal API the web frontend uses. No browser, no Puppeteer, no Chromium, no official API activation required.
SolisCloud has an official monitoring API, but accessing it requires submitting a support ticket and waiting for Solis to manually activate it (setting apiSwitch and apiShowSwitch flags on your account). Many users wait weeks or months stuck on "Nonactivated".
Previous scraping approaches relied on Puppeteer + headless Chromium to navigate the web UI and extract data from the DOM. This:
- Consumed ~600 MB RAM (Chromium process)
- Took ~22 seconds per scrape cycle
- Generated ~9,000 HTTP requests per day (browser loading JS, CSS, images, fonts, Google Maps, Firebase, etc.)
- Broke every time Solis updated their frontend (July 2026: Vue 3 rewrite, new URL structure, removed
p.battery-socDOM element)
This scraper calls the SolisCloud internal API endpoints directly via HTTP, using the same HMAC-SHA1 authentication the web frontend uses. The auth mechanism was reverse-engineered from the frontend JavaScript.
- ~43 MB RAM (Node.js only)
- ~1 second per scrape cycle
- ~1,200 HTTP requests/day (5 API calls per cycle, every 6 minutes)
- No browser dependency - runs on any system with Node.js
- Includes battery data (SoC, power, voltage, direction) that the old DOM scraper couldn't reliably get
This was developed and tested with:
- Inverter: Solis S5-EH1P6K-L (also known as RHI-6K-48ES-5G), hybrid single-phase with battery
- Battery: Soluna 5.12 kWh (SN: 1031052248080003BAT01)
- WiFi Logger: Solis S2-WL-ST (firmware
00010154) - Station capacity: 4.4 kWp
- SolisCloud account type: End user (not installer)
It should work with any SolisCloud account that can log in to the web portal, regardless of inverter model or whether the official API is activated.
Every request to the SolisCloud internal API requires:
- A session cookie (
token=token_<uuid>) obtained from the login endpoint - An HMAC-SHA1 signature in the
Authorizationheader - A Content-MD5 header with the Base64-encoded MD5 hash of the request body
POST https://www.soliscloud.com/api/user/login2
The password is sent as its MD5 hex digest:
{
"userInfo": "your@email.com",
"passWord": "5dcf4da5467c8a61500283572408417a",
"yingZhenType": 1,
"localTime": 1783848004976,
"localTimeZone": 2,
"language": "2"
}The response sets a token=token_<uuid> cookie and an acw_tc cookie. Both must be sent with subsequent requests.
The Authorization header has the format:
Authorization: WEB 2424:<base64-encoded HMAC-SHA1 signature>
The HMAC-SHA1 is computed over this string:
POST\n<Content-MD5>\napplication/json\n<Date-UTC>\n<path-without-api-prefix>
With these components:
- HMAC key:
5704383536604a8bb94c83ebc059aa8c(hardcoded in the frontend JS) - Content-MD5:
Base64(MD5(request_body_string)) - Date-UTC: HTTP date format, e.g.
Sun, 12 Jul 2026 09:20:04 GMT - Path: The API path with the
/apiprefix stripped. Example: forPOST /api/station/list, sign with/station/list
Critical: The signing path must NOT include
/api. Some endpoints (likelogin2anddistributor/detail) are tolerant and accept signatures with or without the prefix. Butstation/listandstation/detailMixreturn403 Forbidden - wrong signif you include/apiin the signature.
| Header | Value |
|---|---|
Content-Type |
application/json;charset=UTF-8 |
Content-MD5 |
Base64-encoded MD5 of the request body |
Authorization |
WEB 2424:<HMAC-SHA1 signature> |
Time |
UTC date string |
Language |
2 (English) |
X-Cloud-Platform |
GLY |
Version |
5.3.001 |
Platform |
Web |
Cookie |
token=token_<uuid>; acw_tc=<value> |
The secret is embedded in the SolisCloud frontend JavaScript bundle (index-CqvhMJaa.js as of July 2026). It's stored as an array of three Base64-encoded strings that are concatenated at runtime:
// From the minified frontend JS:
const mK = ["NTcwNDM4MzUzNjYw", "NGE4YmI5NGM4M2Vi", "YzA1OWFhOGM="];
function hK() {
try {
return mK.map(e => atob(e)).join("");
} catch {
return "";
}
}Decoding each part:
atob("NTcwNDM4MzUzNjYw") = "5704383536604"
atob("NGE4YmI5NGM4M2Vi") = "4a8bb94c83eb"
atob("YzA1OWFhOGM=") = "c059aa8c"
Concatenated: 5704383536604a8bb94c83ebc059aa8c
The signing function in the frontend:
function yK({contentMd5Result: e, date: t, url: n, method: r}) {
const a = vK(); // returns the secret above
return "WEB 2424:" + EK(a, r.toUpperCase() + "\n" + e + "\napplication/json\n" + t + "\n" + n);
}
// EK() computes HMAC-SHA1 and returns Base64The key ID 2424 is a fixed constant embedded in the same function.
To verify the signing works, you can compute it manually:
const crypto = require("crypto");
const secret = "5704383536604a8bb94c83ebc059aa8c";
const body = JSON.stringify({localTime: Date.now(), localTimeZone: 2, language: "2"});
const contentMd5 = crypto.createHash("md5").update(body, "utf8").digest("base64");
const date = new Date().toUTCString();
const path = "/distributor/detail"; // WITHOUT /api
const signString = "POST\n" + contentMd5 + "\napplication/json\n" + date + "\n" + path;
const signature = crypto.createHmac("sha1", secret).update(signString, "utf8").digest("base64");
console.log("Authorization: WEB 2424:" + signature);Authenticates the user. Returns user profile and sets session cookies.
Request body:
{
"userInfo": "your@email.com",
"passWord": "<MD5 hex of password>",
"yingZhenType": 1,
"localTime": 1783848004976,
"localTimeZone": 2,
"language": "2"
}Returns aggregated account-level data.
Request body:
{
"localTime": 1783848005368,
"localTimeZone": 2,
"language": "2"
}Key response fields:
| Field | Description | Example |
|---|---|---|
power |
Current generation | 1.516 |
powerStr |
Unit | "kW" |
energyToday |
Energy generated today | 3.4 |
monthEnergy |
Energy this month | 243.3 |
yearEnergy |
Energy this year | 2.892 |
energyTotal |
Total energy all-time | 5.909 |
batteryChargeTodayEnergy |
Battery charged today | 2 |
batteryDischargeTodayEnergy |
Battery discharged today | 1 |
gridPurchasedTodayEnergy |
Grid import today | 6.27 |
gridSellTodayEnergy |
Grid export today | 0.11 |
homeLoadTodayEnergy |
Home consumption today | 7.46 |
incomeToday / incomeMonth / incomeTotal |
Income stats | 0.51 / 36.51 / 886.35 |
capacity |
Station capacity | 4.4 |
Returns the list of stations (plants). Used to discover the station ID needed for detailMix.
Request body:
{
"pageNo": 1,
"pageSize": 10,
"states": "0",
"stationType": "1",
"selectAreaValue": [],
"dataNumber": 0,
"localTime": 1783848005449,
"localTimeZone": 2,
"language": "2"
}Key response fields (in data.page.records[0]):
| Field | Description | Example |
|---|---|---|
id |
Station ID (needed for detailMix) | "1298491919450219817" |
stationName |
Station name | "Mandor 40" |
state |
1 = normal | 1 |
power |
Current power | 0.798 |
dayEnergy |
Today's energy | 3.4 |
dataTimestampStr |
Last data timestamp | "12/07/2026 11:04:31 (UTC+01:00)" |
inverterCount / inverterOnlineCount |
Inverter status | 1 / 1 |
Returns detailed real-time data for a specific station. This is the most valuable endpoint as it includes battery data not available elsewhere.
Request body:
{
"id": "<station_id from station/list>",
"localTime": 1783847743820,
"localTimeZone": 2,
"language": "2"
}Key response fields:
| Field | Description | Example |
|---|---|---|
batteryPercent |
Battery State of Charge (%) | 30 |
batteryPower |
Battery power in kW (negative = discharging) | -1.509 |
batteryPowerStr |
Unit | "kW" |
batteryDirection |
1 = charging, 2 = discharging | 1 |
storageBatteryVoltage |
Battery voltage | 51.5 |
inverterBatteryCapacity |
Total battery capacity | 5.12 |
inverterBatteryCapacityStr |
Unit | "kWh" |
batteryCount |
Number of batteries | 1 |
batteryChargeEnergy |
Today's charge energy | 2 |
batteryDischargeEnergy |
Today's discharge energy | 0 |
familyLoadPower |
Current house consumption | (available) |
gridPower |
Current grid power | null on S5-EH1P6K-L — see /api/inverter/detail |
gridDirection |
Grid flow direction | null on S5-EH1P6K-L — see /api/inverter/detail |
power |
Current solar power | 0.798 |
dayEnergyStorage |
Today's total yield | 3.4 |
Returns the full inverter telemetry, much richer than detailMix. This is where the instantaneous grid power actually lives (in detailMix the gridPower/gridDirection fields are null, at least on the S5-EH1P6K-L).
Request body:
{
"sn": "<inverter serial number>"
}Key response fields:
| Field | Description | Example |
|---|---|---|
gridDetailVo.gridPower |
Instantaneous grid power (kW, magnitude) | 0 |
gridDetailVo.gridVoltageA / gridCurrentA |
Grid voltage/current per phase | 234.9 / 2.6 |
industryCurrentFlowMapV2 |
Flow direction flags: pvToLoad, pvToBattery, pvToGrid, gridToLoad, batteryToLoad, batteryToGrid (0/1) |
{"pvToLoad":1,...} |
gridPurchasedTodayEnergy |
Today's grid import | 4.86 |
gridSellTodayEnergy |
Today's grid export | 0.04 |
familyLoadPower |
House load (normal circuits) | 0 |
bypassLoadPower |
Backup/bypass load | 0.435 |
totalLoadPower |
Total load = family + bypass | 0.435 |
batteryPowerBms / batteryCapacitySoc / batteryHealthSoh |
BMS data | 1.482 / 50 / 98 |
batteryChargingCurrent / batteryDischargeLimiting |
BMS current limits (A) | 75 / 33.3 |
energyStorageControl / socDischargeSet / socChargingSet |
Inverter settings | "21" / 20 / 10 |
The scraper uses this endpoint to populate currentGridPower (magnitude, kW) and gridDirection in /v2/data. gridDirection is derived from industryCurrentFlowMapV2 (gridToLoad → "import", pvToGrid/batteryToGrid → "export", otherwise "none") so it does not depend on Solis' sign convention for gridPower.
| Endpoint | Description |
|---|---|
/api/user/find |
User profile (includes apiSwitch, apiShowSwitch flags) |
/api/chart/station/day/v2 |
Daily charts with batteryCapacitySocList (SoC history) |
/api/seng/chart |
Detailed time-series: realSoc, batteryPower, gridPower, familyLoadPower |
/api/seng/incomeChart |
Income charts |
/api/inverter/listV2 |
Inverter list |
/api/station/getDetailSample |
Station sample data |
/api/alarm/alarmReadAll |
Alarm status |
/api/weather/get7DayForecast |
Weather forecast for the station location |
/api/station/stationAllEnergy |
All energy statistics |
Pick ONE of these options. The only prerequisite is a SolisCloud account that can log in at https://www.soliscloud.com (the same one you use in the SolisCloud app).
If your Home Assistant runs as HAOS (Home Assistant Green/Yellow, a VM, a Raspberry Pi image...), this is the recommended way: the scraper runs inside Home Assistant itself.
- In Home Assistant, go to Settings → Add-ons → Add-on Store.
- Click the ⋮ menu (top right) → Repositories.
- Paste
https://github.com/gnacho/soliscloud-scraperand click Add, then Close. - The SolisCloud Scraper card appears in the store. Click it → Install.
- Open the Configuration tab and fill in:
solis_username: your SolisCloud emailsolis_password: your SolisCloud passwordservice_username/service_password: invent any user/password pair — this protects the local API. You will need it again in the Home Assistant step below.service_port: leave5561.
- Start the add-on (optionally enable Start on boot and Watchdog).
- Check the Log tab: you should see
Login OKandScrape OK.
Test it: open http://homeassistant.local:5561/v2/data in your browser — it will ask for the service_username/service_password you just invented.
Then continue with Home Assistant Integration below.
For any machine with Docker (NAS, Raspberry Pi, home server...):
git clone https://github.com/gnacho/soliscloud-scraper.git
cd soliscloud-scraper
cp scraper.properties.example scraper.properties
nano scraper.properties # fill in your SolisCloud + local API credentials
docker compose up -d --buildCheck the logs: docker logs -f soliscloud-scraper — you should see Login OK and Scrape OK.
Test: curl -u localuser:localpass http://localhost:5561/v2/data
Plain docker run (no compose) also works:
docker build -t soliscloud-scraper .
docker run -d --name soliscloud-scraper --restart unless-stopped \
-p 5561:5561 -v "$PWD/scraper.properties:/app/scraper.properties:ro" \
soliscloud-scraperRequirements: Node.js 18+ (tested with Node.js 22).
git clone https://github.com/gnacho/soliscloud-scraper.git
cd soliscloud-scraper
npm install
cp scraper.properties.example scraper.properties
nano scraper.properties # your credentials
node scraper.jsYou should see:
Solis Cloud API scraper (no browser) listening on port 5561
Login OK - user: your@email.com
Discovered station ID: 1234567890123456789
Scrape OK - distributor: yes station: yes detailMix: yes batterySoc: 85 duration: 943ms
To keep it running after reboots, create /etc/systemd/system/solis-scraper.service:
[Unit]
Description=SolisCloud Scraper
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/path/to/soliscloud-scraper
ExecStart=/usr/bin/node scraper.js
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.targetsystemctl enable solis-scraper
systemctl start solis-scraper
journalctl -u solis-scraper -fThe easiest way is the ready-made package file: homeassistant/soliscloud-scraper.yaml.
-
Make sure your
configuration.yamlcontains (add it if missing):homeassistant: packages: !include_dir_named packages
-
Copy
homeassistant/soliscloud-scraper.yamlinto your/config/packages/folder (create the folder if it does not exist). -
Edit the 3 values marked
<-- CHANGEinside the file (scraper address + local API credentials):- HAOS add-on with default port: the address is already correct (
http://homeassistant.local:5561/v2/data). - Scraper on another machine: use
http://<that-machine-ip>:5561/v2/data.
- HAOS add-on with default port: the address is already correct (
-
Restart Home Assistant (Settings → System → Restart).
You will get these entities:
| Entity | Meaning |
|---|---|
sensor.solis_battery_soc |
Battery charge (%) |
sensor.solis_solar_power |
Solar production now (kW) |
sensor.solis_battery_power |
Battery power (kW, + charging / − discharging) |
sensor.solis_grid_power |
Grid power (kW, + importing / − exporting) |
sensor.solis_yield_today |
Solar energy today (kWh) |
sensor.solis_grid_import_today |
Grid import today (kWh) |
sensor.solis_grid_export_today |
Grid export today (kWh) |
sensor.solis_house_consumption |
House consumption now (kW) |
The daily kWh sensors use state_class: total_increasing, so you can add them directly to the Energy Dashboard (Settings → Dashboards → Energy).
All endpoints require Basic Auth.
| Endpoint | Description |
|---|---|
GET /v2/data |
Normalized data with all fields (recommended) |
GET /data |
Raw captured data (distributor + station + detailMix objects) |
GET /v1/data |
Same as /data |
GET /refresh |
Trigger an immediate refresh cycle |
| Puppeteer + Chromium | This (API Direct) | |
|---|---|---|
| RAM | ~600 MB | ~43 MB |
| Time per cycle | ~22 seconds | ~1 second |
| Requests/day | ~9,000 | ~1,200 |
| Dependencies | Node.js + Chromium | Node.js only |
| Battery SoC | DOM scraping (fragile) | API field batteryPercent |
| Battery power/voltage | Not available | Available |
| Portability | Needs Chromium | Runs anywhere |
The official SolisCloud API exists and is documented (PDF), but requires manual activation by Solis support. To request access:
- Go to https://solis-service.solisinverters.com/en/support/tickets/new
- Select "API Access Request"
- Provide your SolisCloud email address
- Wait for Solis to process the request
Once activated, the API management page appears at https://www.soliscloud.com/service/apiManagement where you can generate API keys.
The user profile flags that control this:
apiSwitch: 0/1- Whether the API is enabledapiShowSwitch: 0/1- Whether the API management menu is visibleuserFunctionsmust includeA0507
This scraper does not need the official API to be activated. It uses the same internal API the web frontend uses.
This project reverse-engineers the internal API of SolisCloud for personal, non-commercial monitoring use. The API endpoints, authentication mechanism, and HMAC secret are extracted from the publicly served frontend JavaScript and may change without notice. This is not affiliated with Ginlong/Solis in any way. Use at your own risk.
MIT