SmartPVMS Northbound API Skill (v25.3.0)
Quick Start
Before writing ANY code, read the relevant reference files:
Task Read First
Query plant/device data references/api-endpoints-query.md
Control battery/inverter references/api-endpoints-control.md
Know which fields exist references/data-fields.md
Handle errors & retries references/error-codes.md
Identify device types references/device-types.md
Architecture Pattern
Runtime: Bun | HTTP client: axios | Language: TypeScript
import axios, { AxiosInstance } from "axios";
interface SmartPVMSConfig { baseUrl: string; // e.g. "https://intl.fusionsolar.huawei.com" // API Account mode userName?: string; systemCode?: string; // OAuth Connect mode accessToken?: string; }
class SmartPVMSClient { private http: AxiosInstance; private xsrfToken: string | null = null; private tokenExpiresAt = 0; private config: SmartPVMSConfig;
constructor(config: SmartPVMSConfig) { this.config = config; this.http = axios.create({ baseURL: config.baseUrl, headers: { "Content-Type": "application/json" }, timeout: 30_000, }); }
Two Authentication Modes
Mode 1: API Account (XSRF-TOKEN)
-
Created by company admin in FusionSolar WebUI
-
Max 5 accounts per company
-
Token validity: 30 minutes (auto-extended on each call)
-
One online session per account — repeated login invalidates previous token
-
Supports: All query APIs + some control APIs
async login(): Promise<void> { const res = await this.http.post("/thirdData/login", { userName: this.config.userName, systemCode: this.config.systemCode, // NOT "password"! }); // Token is in response HEADER, not body const token = res.headers["xsrf-token"] || res.headers["set-cookie"] ?.find((c: string) => c.includes("XSRF-TOKEN")) ?.match(/XSRF-TOKEN=([^;]+)/)?.[1]; if (!token) throw new Error("No XSRF-TOKEN in response headers"); this.xsrfToken = token; this.tokenExpiresAt = Date.now() + 25 * 60 * 1000; // 25min safety margin }
private async ensureAuth(): Promise<void> { if (!this.xsrfToken || Date.now() >= this.tokenExpiresAt) { await this.login(); } }
Mode 2: OAuth Connect (Bearer Token)
-
For third-party app integration, owner authorizes access
-
OAuth server: https://oauth2.fusionsolar.huawei.com
-
Access token validity: 60 minutes
-
Refresh token used to obtain new access tokens
-
Scopes: pvms.openapi.basic , pvms.openapi.control
-
Required for ALL Control APIs (Section 5.2)
// Step 1: Build authorization URL (user opens in browser)
const authUrl = https://oauth2.fusionsolar.huawei.com/rest/dp/uidm/oauth2/v1/authorize?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}&scope=pvms.openapi.basic%20pvms.openapi.control;
// Step 2: Exchange code for tokens (Content-Type: x-www-form-urlencoded!) const tokenRes = await axios.post( "https://oauth2.fusionsolar.huawei.com/rest/dp/uidm/oauth2/v1/token", new URLSearchParams({ grant_type: "authorization_code", code: authCode, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, }), { headers: { "Content-Type": "application/x-www-form-urlencoded" } } ); // Response: { access_token, refresh_token, expires_in: 3600, scope, token_type: "Bearer" }
// Step 3: Refresh when expired const refreshRes = await axios.post( "https://oauth2.fusionsolar.huawei.com/rest/dp/uidm/oauth2/v1/token", new URLSearchParams({ grant_type: "refresh_token", refresh_token: currentRefreshToken, client_id: clientId, client_secret: clientSecret, }), { headers: { "Content-Type": "application/x-www-form-urlencoded" } } );
// Usage: Add to every API request
headers: { "Authorization": Bearer ${accessToken} }
Request Wrapper with Error Handling & Retry
class SmartPVMSError extends Error {
constructor(
public failCode: number,
message: string,
public params?: Record<string, unknown>
) {
super(SmartPVMS Error ${failCode}: ${message});
this.name = "SmartPVMSError";
}
get isRetryable(): boolean {
return [407, 429, 20200, 20614].includes(this.failCode);
}
get isAuthError(): boolean {
return [305, 20002, 20003].includes(this.failCode);
}
}
async request<T>(path: string, body: object, maxRetries = 3): Promise<T> {
await this.ensureAuth();
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const headers: Record<string, string> = {};
if (this.xsrfToken) headers["XSRF-TOKEN"] = this.xsrfToken;
if (this.config.accessToken)
headers["Authorization"] = Bearer ${this.config.accessToken};
const res = await this.http.post(path, body, { headers });
const data = res.data;
if (data.success === false || data.failCode !== 0) {
const err = new SmartPVMSError(
data.failCode ?? -1,
data.message ?? "Unknown error",
data.params
);
if (err.isAuthError) {
await this.login();
continue; // retry with new token
}
if (err.isRetryable && attempt < maxRetries) {
const delay =
err.failCode === 429
? 60_000 // system rate limit: wait 60s+
: 1000 * Math.pow(2, attempt); // exponential backoff
await Bun.sleep(delay);
continue;
}
throw err;
}
// Extend token expiry on successful call
this.tokenExpiresAt = Date.now() + 25 * 60 * 1000;
return data.data as T;
} catch (err) {
if (err instanceof SmartPVMSError) throw err;
if (attempt < maxRetries) {
await Bun.sleep(1000 * Math.pow(2, attempt));
continue;
}
throw err;
}
} throw new Error("Max retries exceeded"); }
CRITICAL Notes & Gotchas
All APIs are HTTPS POST with JSON body
-
Exception: OAuth authorization URL is GET (browser redirect)
-
Exception: OAuth token endpoint uses application/x-www-form-urlencoded
Timestamps are ALWAYS in milliseconds
const collectTime = Date.now(); // ms, not seconds!
Batch Size Limits
Resource Max per request
Plants (stationCodes) 100
Devices (sns/devIds) 100
Historical device data 1 device, 24 hours max
Control tasks (battery mode, params, power) 10 plants
Charge/discharge tasks 100 plants
Dispatch tasks 1 plant + 1 battery
Grid Meter Power Unit is WATTS, not kW!
The grid meter (devTypeId=17) and power sensor (devTypeId=47) return active_power in W (watts), unlike inverters which return in kW.
Report Data Time Logic
-
Hourly: Send any timestamp of the day → returns all hourly data for that day (up to 24 records)
-
Daily: Send any timestamp of the month → returns all daily data for that month (up to 31 records)
-
Monthly: Send any timestamp of the year → returns all monthly data for that year (up to 12 records)
-
Yearly: Send any timestamp → returns all yearly data available
Real-Time Data Collection Interval
-
Real-time plant/device data: refreshed every 5 minutes
-
Total revenue (total_income ): refreshed every 1 hour
inverter_power Ambiguity
The inverter_power key in report APIs is ambiguous. Use these instead:
-
PVYield — PV energy yield
-
inverterYield — Inverter energy yield
Plant ID Format
Plant IDs look like "NE=33554875" . Multiple IDs are comma-separated as a single string: "NE=33554875,NE=33554876" .
Flow Control (API Account Mode)
Read references/error-codes.md for detailed rate limit formulas. Key rules:
-
Real-time data APIs: ceil(count/100) calls per 5 minutes
-
Report/list APIs: ceil(count/100) + 24 calls per day
-
Historical data: ceil(devices/60/10) calls per second
-
Alarm API: ceil(count/100) calls per 30 minutes
Flow Control (OAuth Connect Mode)
-
Basic APIs: 1000 calls/day per owner
-
Control APIs: 100 calls/day per owner
Control APIs Are OAuth-Only
All Control APIs (battery charge/discharge, battery mode, battery params, inverter power, dispatch) require OAuth Connect mode. They are residential-scenario only.
Task Status Polling
After delivering a control task, query its status. Status values:
-
RUNNING — Task is still executing (updated every ~3 minutes)
-
SUCCESS — Task completed successfully
-
FAIL — Task failed (check message for: FAILURE , TIMEOUT , BUSY , INVALID , EXCEPTION )
-
Tasks timeout after 24 hours if not completed
Exception Responses (Non-standard)
Control APIs may return HTTP 400/500 with a different response format:
{ "exceptionId": "framwork.remote.Paramerror", "exceptionType": "ROA_EXFRAME_EXCEPTION", "descArgs": null, "reasonArgs": ["tasks"], "detailArgs": ["tasks size must be between 1 and 10"], "adviceArgs": null }
Always handle both { success, failCode, data } and exception format responses.
TypeScript Interface Templates
// Standard API response wrapper interface ApiResponse<T> { success: boolean; failCode: number; message: string | null; data: T; params?: Record<string, unknown>; }
// Plant from plant list interface Plant { plantCode: string; // "NE=33554875" plantName: string; plantAddress: string | null; longitude: number | null; latitude: number | null; capacity: number; // kWp contactPerson: string; contactMethod: string; gridConnectionDate: string; // ISO 8601 with timezone }
// Device from device list interface Device { id: number; devDn: string; // "NE=45112560" devName: string; stationCode: string; esnCode: string; // device SN devTypeId: number; model: string; softwareVersion: string; optimizerNumber: number; invType: string; // inverter model (inverters only) longitude: number | null; latitude: number | null; }
// Real-time data item interface DataItem { stationCode?: string; sn?: string; devDn?: string; collectTime?: number; dataItemMap: Record<string, number | string | null>; }
// Alarm interface Alarm { stationCode: string; stationName: string; alarmId: number; alarmName: string; alarmType: number; // 0:other, 1:transposition, 2:exception, 3:protection, 4:notification, 5:alarm_info alarmCause: string; causeId: number; repairSuggestion: string; devName: string; devTypeId: number; esnCode: string; lev: number; // 1:critical, 2:major, 3:minor, 4:warning status: number; // 1:active (not processed) raiseTime: number; // ms timestamp }
// Control task result interface TaskResult { plantCode: string; sn?: string; status: "RUNNING" | "SUCCESS" | "FAIL"; message: string | null; }
Code Generation Guidelines
-
Always use Bun.sleep() for delays (not setTimeout )
-
All timestamps in milliseconds (use Date.now() )
-
Add JSDoc comments explaining each API's constraints
-
Batch plant/device IDs respecting the 100 max limit
-
Include proper TypeScript types for all API responses
-
Handle both standard response format AND exception format
-
For polling tasks: check every 30-60s, respect the 3-minute update interval
-
Log rate limit errors (407/429) with remaining quota info from params
Reference Files Index
File Contents Lines
references/api-endpoints-query.md
All 14 query APIs: URLs, params, response fields, examples ~600
references/api-endpoints-control.md
All 10 control APIs: URLs, params, response fields, practices ~500
references/data-fields.md
Every data field per device type per API granularity ~800
references/error-codes.md
156 error codes + flow control rules + FAQs ~400
references/device-types.md
Device type IDs, inverter states, alarm types, extra devTypeIds ~200