homebridge-sunsynk
Version:
A plugin for homebridge to integrate Sunsynk inverter into HomeKit
263 lines (211 loc) • 7.96 kB
JavaScript
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;