自作した自家発電機の状態を監視するシステムの根幹部分をどうやって作ったか、の記録。
コントローラがブラウザに返すhtml/jsをリバースして作ったんですが、一応メーカのエンジニアに問い合わせてOK頂いたので、安心して公開することにします。
発電状況を外部から取得できるコントローラを選別する
「発電状況を外部から取得できるコントローラ」って、意外と少ないんです。少なくとも個人で手が出る価格帯の範囲では。
外部から取得できないなら、センサー類を自前で揃えて自作するしかないのだけど、家電が使える容量のシステムだと流す電流量が多いので、それに耐えるセンサーとなると… コストが嵩むことになります。そもそも入手できないかも…。
そこでこのTristar社製チャージコントローラTS-MPPT-60です。
日本円で10万円程度にも関わらず、標準でEthernetポートを備えている上、HTTP経由でバッテリ電圧、太陽光パネル電圧、充電流量、放電流量、ヒートシンク温度に至るまで様々な情報を得ることができる、お得感満載な製品です。
この情報を収集する手段を用意し、収集したデータをXivelyといったクラウド上のデータベースに記録してグラフ化したい、というわけです。
TS-MPPT-60のAPI仕様
TS-MPPT-60から得る情報や、その取得方法の仕様はTriStar-MPPT Modbus specification documentを見れば分かるよと、Tristar社のエンジニアの方が教えてくれたんですが… 結構な文量な上、まさに「仕様書」って感じで読みづらい。
ならば、ブラウザ経由でチャージコントローラにアクセスした際に参照するJavascriptソースを解析した方が、目的達成には近道。
のはず。
発電状況を取得する具体的な方法の検証
ということで、まずは普通にブラウザで表示されるフロントエンドの解析をしてみることに。
ChromeのDevelopper Toolsを使う
ブラウザで取得できる情報は以下。
ブラウザで表示できるということは、TS-MPTT-60が何かしらのAPIを提供しているはす。
ChromeのDevelopper Toolを使ってHTML/CSS/JSを引っこ抜いて眺めてみました。
index.html
HTML5をまともに勉強したことがなかったんですが、関数名や変数名を追っていけば、何を言っているのかは大体わかるもの。
Battery項目に着目する。Chromeのデベロッパツールを使って見てみると、battery voltageの値は、fD0というname属性がついたform要素内にある、input要素のlblcurrentValue属性に対して、誰かが属性値を設定しているはず。
index.htmlのヘッダを見てみると、以下のようにJavaScriptを読み込んでいる。また、ホームページの読み込み完了イベント発生時にLVInit()をコールするようになっている。
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8"><meta name="TriStarMPPT60Live" content="">
<title>TriStar MPPT - Live Data</title>
<link href="favicon.ico" rel="shortcut icon" type="image/x-icon">
<link href="ss.css" rel="stylesheet" type="text/css">
<script type="text/javascript" src="MBID.js"></script>
<script type="text/javascript" src="utilities.js"></script>
<script type="text/javascript" src="liveview.js"></script>
</head>
<body onload="LVInit()">
...
</body>
</html>
liveview.js
jsファイルをfD0でgrepしてみると、ScaledValueDisplayClassを発見。ただ、できる限り転送量を減らすためか、改行もスペースもなし、ABCDE…等のアルファベット1文字で全ての変数が宣言されており、とてもreadableとは呼べないコード…。
なので、以下に引用するjsコードは全て、引数名をそれなりの意味を込めた名前に修正してみた。
LVInit()
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配列に格納し、一定時間毎に全ての要素を更新するようになっている。
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()が返す値を格納して表示するようになっている。
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()で取得できるらしい。
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()に降りてみる。
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して取った値を元に、計算方法を真似れば良い。
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コマンドでインストールして、
$ pip install tsmppt60_driver
ヘルプ表示すれば使い方もわかるようになってます。
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
実際にやってみるとこうなります。
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}}
まとめ
言語が違っても、その言語をほぼ勉強していなくても、意外と読み解けるもんですね。
英文法をある程度理解していて、パラダイムがよほど違ってなければ、ほとんど似たようなもんだから、そりゃそうか。