UNPKG

homebridge-sunsynk

Version:

A plugin for homebridge to integrate Sunsynk inverter into HomeKit

263 lines (211 loc) 7.96 kB
const crypto = require('crypto'); const CryptoJS = require('crypto-js'); const axios = require('axios'); const https = require('https'); const httpsAgent = new https.Agent({ rejectUnauthorized: false }); class sunsynkAPI { constructor(username, password, appKey, appSecret, log) { this.username = username; this.password = password; this.appKey = appKey; this.appSecret = appSecret; this.log = log; this.tokenInfo = { access_token: '', refresh_token: '', uuid: '', expires_in: 0 } this.preURL = "https://api.sunsynk.net/"; this.nonce = this.createUuid(); this.body = ''; this.md5 = ''; this.signature = ''; this.signatureHeaders = ''; this.headers = { 'content-type': 'application/json', 'accept': 'application/json', 'Content-MD5': this.md5, 'X-Ca-Nonce': this.nonce, 'X-Ca-Key': this.appKey, /*'X-Ca-Signature': this.signature, 'X-Ca-Signature-Headers': this.signatureHeaders*/ }; } sign_stuff(method, url) { var textToSign = ''; textToSign += method + "\n"; textToSign += this.headers['accept'] + "\n"; textToSign += this.md5 + "\n"; textToSign += this.headers['content-type'] + "\n"; textToSign += "\n"; var headers = this.headersToSign(); this.signatureHeaders = ''; var sortedKeys = Array.from(headers.keys()).sort(); for (var headerName of sortedKeys) { textToSign += headerName + ":" + headers.get(headerName) + "\n"; this.signatureHeaders = this.signatureHeaders ? this.signatureHeaders + "," + headerName : headerName; } textToSign += this.urlToSign(url); var hash = CryptoJS.HmacSHA256(textToSign, this.appSecret); this.signature = hash.toString(CryptoJS.enc.Base64); this.headers['X-Ca-Signature'] = this.signature; this.headers['X-Ca-Signature-Headers'] = this.signatureHeaders; } async request(method, path, params = null) { // Refresh token if expiring in next 60 seconds if (Date.now() > this.tokenInfo.expires_in - 60_000) { this.log.log('[Sunsynk] Access token expired, re-authenticating...'); await this.login(); } const res = await axios({ baseURL: 'https://api.sunsynk.net/api/v1/', url: path, method, headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${this.apiToken}` }, params }); return res.data.data; } async get(path, params, body) { return this.request('GET', path, params, body); } async post(path, params, body) { return this.request('POST', path, params, body); } async login() { const requestBody = { username: this.username, password: this.password, grant_type: 'password', client_id: 'openapi' }; const jsonBody = JSON.stringify(requestBody); // 1️⃣ Content-MD5 (EXACT match) const md5 = CryptoJS.MD5(jsonBody).toString(CryptoJS.enc.Base64); // 2️⃣ Nonce const nonce = crypto.randomUUID(); // 3️⃣ Headers const headers = { accept: 'application/json', 'content-type': 'application/json', 'Content-MD5': md5, 'X-Ca-Key': this.appKey, 'X-Ca-Nonce': nonce }; // 4️⃣ Build textToSign let textToSign = 'POST\n'; textToSign += headers.accept + '\n'; textToSign += md5 + '\n'; textToSign += headers['content-type'] + '\n'; textToSign += '\n'; // 5️⃣ Canonicalize x-ca-* headers const headersToSign = {}; Object.keys(headers).forEach(h => { const name = h.toLowerCase(); if (name.startsWith('x-ca-')) { headersToSign[name] = headers[h]; } }); const sortedKeys = Object.keys(headersToSign).sort(); const signatureHeaders = sortedKeys.join(','); sortedKeys.forEach(k => { textToSign += `${k}:${headersToSign[k]}\n`; }); // 6️⃣ Append path textToSign += '/oauth/token'; // 7️⃣ Sign const hash = CryptoJS.HmacSHA256(textToSign, this.appSecret); const signature = CryptoJS.enc.Base64.stringify(hash); // 8️⃣ Final headers headers['X-Ca-Signature'] = signature; headers['X-Ca-Signature-Headers'] = signatureHeaders; // 9️⃣ Request const res = await axios({ method: 'POST', url: 'https://openapi.sunsynk.net/oauth/token', headers, data: jsonBody, httpsAgent }); const { access_token, refresh_token, expires_in } = res.data.data; this.tokenInfo = { access_token, refresh_token, expires_in: Date.now() + expires_in * 1000 }; this.apiToken = access_token; // 🔥 THIS WAS MISSING return true; } async refreshAccessTokenIfNeed(path) { if (path.startsWith('/oauth/token')) { return; } if (this.tokenInfo.expires_in - 60 * 1000 > new Date().getTime()) { return; } //this.tokenInfo.access_token = ''; } calcMd5(data) { return crypto.createHash('md5').update(data).digest('base64'); } createUuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } headersToSign() { var headers = new Map(); for (var name in this.headers) { name = name.toLowerCase(); if (!name.startsWith('x-ca-')) { continue; } if (name === "x-ca-signature" || name === "x-ca-signature-headers" || name == "x-ca-key" || name === 'x-ca-nonce' || name === 'x-ca-stage') { continue; } var value = this.headers[name]; headers.set(name, value); } headers.set('x-ca-key', this.appKey); headers.set('x-ca-nonce', this.nonce); return headers; } urlToSign(url_send) { var params = new Map(); var contentType = this.headers["content-type"]; if (contentType && contentType.startsWith('application/x-www-form-urlencoded')) { const formParams = this.body.split("&"); formParams.forEach((p) => { const ss = p.split('='); params.set(ss[0], ss[1]); }) } const ss = url_send.split('?'); if (ss.length > 1 && ss[1]) { const queryParams = ss[1].split('&'); queryParams.forEach((p) => { const ss = p.split('='); params.set(ss[0], ss[1]); }) } var sortedKeys = Array.from(params.keys()) sortedKeys.sort(); var first = true; var qs for (var k of sortedKeys) { var s = k + "=" + params.get(k); qs = qs ? qs + "&" + s : s; console.log("key=" + k + " value=" + params.get(k)); } var url = ss[0]; return qs ? url + "?" + qs : url; } } module.exports = sunsynkAPI;