smartpvms-api

📁 takzobye/smartpvms-northbound-api-skills 📅 3 days ago
3
总安装量
3
周安装量
#58694
全站排名
安装命令
npx skills add https://github.com/takzobye/smartpvms-northbound-api-skills --skill smartpvms-api

Agent 安装分布

opencode 3
gemini-cli 3
antigravity 3
claude-code 3
github-copilot 3
codex 3

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 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

  1. Always use Bun.sleep() for delays (not setTimeout)
  2. All timestamps in milliseconds (use Date.now())
  3. Add JSDoc comments explaining each API’s constraints
  4. Batch plant/device IDs respecting the 100 max limit
  5. Include proper TypeScript types for all API responses
  6. Handle both standard response format AND exception format
  7. For polling tasks: check every 30-60s, respect the 3-minute update interval
  8. 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