smartpvms-api
npx skills add https://github.com/takzobye/smartpvms-northbound-api-skills --skill smartpvms-api
Agent 安装分布
Skill 文档
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 yieldinverterYieldâ 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) + 24calls 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 successfullyFAILâ Task failed (checkmessagefor: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 (notsetTimeout) - 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 |