太陽光チャージコントローラ TS-MPPT-60 のステータス取得用Pythonパッケージを作ってみた。

自作した自家発電機の状態を監視するシステムの根幹部分をどうやって作ったか、の記録。

コントローラがブラウザに返すhtml/jsをリバースして作ったんですが、一応メーカのエンジニアに問い合わせてOK頂いたので、安心して公開することにします。

まずはチャージコントローラ選別

「発電状況を外部から取得できるコントローラ」って、意外と少ないんです。少なくとも個人で手が出る価格帯の範囲では。

外部から取得できないなら、センサー類を自前で揃えて自作するしかないのだけど、家電が使える容量のシステムだと流す電流量が多いので、それに耐えるセンサーとなると… コストが嵩むことになります。そもそも入手できないかも…。

そこでこのTristar社製チャージコントローラTS-MPPT-60です。

日本円で10万円程度にも関わらず、標準でEthernetポートを備えている上、HTTP経由でバッテリ電圧、太陽光パネル電圧、充電流量、放電流量、ヒートシンク温度に至るまで様々な情報を得ることができる、お得感満載な製品です。

この情報を収集する手段を用意し、収集したデータをXivelyといったクラウド上のデータベースに記録してグラフ化したい、というわけです。

Tristar社製 TS-MPPT-60 API仕様

TS-MPPT-60から得る情報や、その取得方法の仕様はTriStar-MPPT Modbus specification documentを見れば分かるよと、Tristar社のエンジニアの方が教えてくれたんですが… 結構な文量な上、まさに「仕様書」って感じで読みづらい。

ならば、ブラウザ経由でチャージコントローラにアクセスした際に参照するJavascriptソースを解析した方が、目的達成には近道。

のはず。

発電状況取得方法の検証

ということで、まずは普通にブラウザで表示されるフロントエンドの解析をしてみることに。

ChromeのDevelopper Toolsを使う

ブラウザで取得できる情報は以下。
web_tsmppt60.png

ブラウザで表示できるということは、TS-MPTT-60が何かしらのAPIを提供しているはす。

ChromeのDevelopper Toolを使ってHTML/CSS/JSを引っこ抜いて眺めてみました。

index.html

HTML5をまともに勉強したことがなかったんですが、関数名や変数名を追っていけば、何を言っているのかは大体わかるもの。

Battery項目に着目する。Chromeのデベロッパツールを使って見てみると、battery voltageの値は、fD0というname属性がついたform要素内にある、input要素のlblcurrentValue属性に対して、誰かが属性値を設定しているはず。

web_tsmppt60_elem_battery.png

index.htmlのヘッダを見てみると、以下のようにJavaScriptを読み込んでいる。また、ホームページの読み込み完了イベント発生時にLVInit()をコールするようになっている。

html


TriStar MPPT - Live Data


<body onload=""LVInit()"">
...
</body>


liveview.js

jsファイルをfD0でgrepしてみると、ScaledValueDisplayClassを発見。ただ、できる限り転送量を減らすためか、改行もスペースもなし、ABCDE…等のアルファベット1文字で全ての変数が宣言されており、とてもreadableとは呼べないコード…。

なので、以下に引用するjsコードは全て、引数名をそれなりの意味を込めた名前に修正してみた。

LVInit()

language-javascript
var rowsToUpdate=new Array();
var UPDATE_FREQ_SECS=5;
var Vb=new ScaledValueDisplayClass(MBID,38,""V"",""fD0"",""Battery Voltage"",1);
var VbT=new ScaledValueDisplayClass(MBID,51,""V"",""fD1"",""Target Voltage"",1);
var IbC=new ScaledValueDisplayClass(MBID,39,""A"",""fD2"",""Charge Current"",1);
...

上記のように各要素のクラスインスタンスを生成し、LVInit()でrowsToUpdate配列に格納し、一定時間毎に全ての要素を更新するようになっている。

language-javascript
function LVInit(){
ShowMenu();
Factors.Init();
rowsToUpdate[rowsToUpdate.length]=Vb;
rowsToUpdate[rowsToUpdate.length]=VbT;
rowsToUpdate[rowsToUpdate.length]=IbC;
...
intervalHandle=setInterval(updateAllLVText,100)
}

ScaledValueDisplayClass

先のコードと、index.htmlに記述された要素や属性と合わせて読むと、fD0フォームのlblDataName属性が付いたinput要素に対してはそのままlblNameを、lblcurrentValue属性が付いたinput要素に対してはGetScaledValue()が返す値を格納して表示するようになっている。

language-javascript
function ScaledValueDisplayClass(MBID, MBaddress, ScaleFactor, FormName, LabelName, Register){
this.MBID=MBID;
this.MBaddress=MBaddress;
this.frmName=FormName;
this.lblName=LabelName;
this.ScaleFactor=ScaleFactor;

this.updateLVText=function(){
    try{
        document.forms[this.frmName].elements.lblDataName.value = this.lblName.toString();
        document.forms[this.frmName].elements.lblcurrentValue.value = GetScaledValue(this.MBID, this.MBaddress, this.ScaleFactor, Register).toString() + "" "" + this.ScaleFactor.toString();
        return 1
    }
    catch(G){
        return 0
    }
}

}

GetScaledValue()

そのGetScaledValue()が以下。[V]、[A]、[W]、[Ah]、[kWh]、それぞれの単位に応じた計算アルゴリズムが見て取れる。

計算元の生データはMBJSReadModbusInts()で取得できるらしい。

language-javascript
function GetScaledValue(MBID, MBaddress, ScaleFactor, Register){
var rawValue = 0;
rawValue = MBJSReadModbusInts(MBP, MBID.toString(), MBaddress.toString(), Register);

if(Register > 1){
    var values = rawValue.split(""#"");
    rawValue = (parseInt(values[0]) * 65536) + parseInt(values[1])
}
else{
    rawValue <<= 16;
    rawValue >>= 16
}

if(ScaleFactor.toString() == ""V""){
    return((rawValue * Factors.VScale) / 32768 / 10).toFixed(2)
}
else{
    if(ScaleFactor.toString() == ""A""){
        return((rawValue*Factors.IScale) / 32768 / 10).toFixed(1)
    }
    else{
        if(ScaleFactor.toString() == ""W""){
            return((rawValue * Factors.IScale * Factors.VScale) / 131072 / 100).toFixed(0)
        }
        else{
            if(ScaleFactor.toString() == ""Ah""){
                return(rawValue * 0.1).toFixed(1)
            }
            else{
                if(ScaleFactor.toString() == ""kWh""){
                    return(rawValue).toFixed(0)
                }
                else{
                    return(rawValue).toFixed(2)
                }
            }
        }
    }
}

}

utilities.js

MBJSReadModbusInts()

そのMBJSReadModbusInts()が以下。ここで “”#”” で区切った値をreturnするので、GetScaledValue()側で “”#”” でsplitした上でulong値に計算し直す処理があるわけですな。

さらにMBJSReadCSV()に降りてみる。

language-javascript
function MBJSReadModbusInts(MBPVAL, MBIDVAL, MBaddress, Register){
var rawValueGotByCgi = MBJSReadCSV(MBPVAL, MBIDVAL, MBaddress, Register);
var valuesGotByCgi = rawValueGotByCgi.split("","");
var idxMax = valuesGotByCgi[2];
var idxValue = 3;
var retValueString = """";
var retValueShort;

while(idxValue < parseInt(idxMax) + 2){
    retValueShort = (parseInt(valuesGotByCgi[idxValue++]) * 256);
    retValueShort += parseInt(valuesGotByCgi[idxValue++]);

    if(idxValue < parseInt(idxMax) + 2){
        retValueString += retValueShort.toString() + ""#"";
    }
    else{
        retValueString += retValueShort.toString();
    }
}

return retValueString;

}

MBJSReadModbusInts(), ajaxget()

ここまできてやっと、MBCSV.cgi経由でデータをajaxgetするコードに辿り着きました。

ajaxgetってことは、ページ更新せずにデータのみ取得するんでしょうね。多分。

ここまで分かれば、後はPythonのrequestsモジュールでMBCSV.cgiにget requestして取った値を元に、計算方法を真似れば良い。

language-javascript
function MBJSReadCSV(MBPVAL, MBIDVAL, MBaddress, Register){
return ajaxget(MBIDVAL, MBaddress, Register, 4, false)
}

function ajaxget(MBPVAL, MBaddress, Register, Field, IsAsync){
var ajax = new ajaxRequest();
var response = """";
var id = encodeURIComponent(MBPVAL);
var field = encodeURIComponent(Field);
var addressHigh = encodeURIComponent(parseInt(MBaddress) >> 8);
var addressLow = encodeURIComponent(parseInt(MBaddress) & 255);
var registerHigh = encodeURIComponent(parseInt(Register) >> 8);
var registerLow = encodeURIComponent(parseInt(Register) & 255);

ajax.open(""GET"", ""MBCSV.cgi?ID="" + id + ""&F="" + field + ""&AHI="" + addressHigh + ""&ALO="" + addressLow + ""&RHI="" + registerHigh + ""&RLO="" + registerLow, IsAsync);
ajax.send(null);

if(!IsAsync){
    response = ajax.responseText;
}

return response;

}

発電状況を取得するPythonパッケージ

そういったPythonモジュールを作成し、githubに置きました。

PyPIにも公開しているので、ご興味ある方はpip installしてみてください。

簡単な使い方紹介

pipコマンドでインストールして、

bash
$ pip install tsmppt60_driver

ヘルプ表示すれば使い方もわかるようになってます。

python
In [1]: import tsmppt60_driver as ts

In [2]: ts.SystemStatus?
Init signature: ts.SystemStatus(host)
Docstring:
This is class to get the system status of TS-MPPT-60. Use this like below.

print(SystemStatus(""192.168.1.20"").get())

{'Amp Hours': {'group': 'Counter', 'unit': 'Ah', 'value': 18097.9},
 'Array Current': {'group': 'Array', 'unit': 'A', 'value': 1.4},
 'Array Voltage': {'group': 'Array', 'unit': 'V', 'value': 53.41},
 'Battery Voltage': {'group': 'Battery', 'unit': 'V', 'value': 23.93},
 'Charge Current': {'group': 'Battery', 'unit': 'A', 'value': 3.2},
 'Heat Sink Temperature': {'group': 'Temperature', 'unit': 'C', ...},
 'Kilowatt Hours': {'group': 'Counter', 'unit': 'kWh', 'value': 237.0},
 'Target Voltage': {'group': 'Battery', 'unit': 'V', 'value': 28.6}}

The above data is limited information. You can disable the limitter
like below.

print(SystemStatus(""192.168.1.20"", False).get())

{'Amp Hours': {'group': 'Counter', 'unit': 'Ah', 'value': 18097.8},
 'Array Current': {'group': 'Array', 'unit': 'A', 'value': 1.3},
 'Array Voltage': {'group': 'Array', 'unit': 'V', 'value': 53.41},
 'Battery Temperature': {'group': 'Temperature', 'unit': 'C', ...},
 'Battery Voltage': {'group': 'Battery', 'unit': 'V', 'value': 24.01},
 'Charge Current': {'group': 'Battery', 'unit': 'A', 'value': 3.2},
 'Heat Sink Temperature': {'group': 'Temperature', 'unit': 'C', ...},
 'Kilowatt Hours': {'group': 'Counter', 'unit': 'kWh', 'value': 237.0},
 'Output Power': {'group': 'Battery', 'unit': 'W', 'value': 76.0},
 'Sweep Pmax': {'group': 'Array', 'unit': 'W', 'value': 73.0},
 'Sweep Vmp': {'group': 'Array', 'unit': 'V', 'value': 53.41},
 'Sweep Voc': {'group': 'Array', 'unit': 'V', 'value': 60.05},
 'Target Voltage': {'group': 'Battery', 'unit': 'V', 'value': 28.6}}

Init docstring:
Initialize class object.

Keyword arguments:
host -- TS-MPPT-60 host address like ""192.168.1.20""
File: ~/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/site-packages/tsmppt60_driver/init.py
Type: type

実際にやってみるとこうなります。

python
In [1]: import tsmppt60_driver as ts

In [3]: d = ts.SystemStatus(""192.168.1.20"")

In [4]: d.get()
Out[4]:
{'Amp Hours': {'group': 'Counter', 'unit': 'Ah', 'value': 32885.7},
'Array Current': {'group': 'Array', 'unit': 'A', 'value': 0.0},
'Array Voltage': {'group': 'Array', 'unit': 'V', 'value': 0.3900146484375},
'Battery Voltage': {'group': 'Battery',
'unit': 'V',
'value': 23.631591796875},
'Charge Current': {'group': 'Battery', 'unit': 'A', 'value': -0.09521484375},
'Heat Sink Temperature': {'group': 'Temperature', 'unit': 'C', 'value': 11},
'Kilowatt Hours': {'group': 'Counter', 'unit': 'kWh', 'value': 604},
'Target Voltage': {'group': 'Battery', 'unit': 'V', 'value': 0.0}}

まとめ

言語が違っても、その言語をほぼ勉強していなくても、意外と読み解けるもんですね。

英文法をある程度理解していて、パラダイムがよほど違ってなければ、ほとんど似たようなもんだから、そりゃそうか。

スポンサーリンク

シェアする

  • このエントリーをはてなブックマークに追加

フォローする