ESP8266でBトレ(第11回 ソフトウェア編その2)ZoneController基板のソフトと、対向するサーバー側のソフト

ZoneController(基板)のソフトウェアとパソコン側(ZcProxy)のソフトについて

おじさんが考えているシステムの全体構成はこうなっていますが、 f:id:cacao1:20151128235918p:plain レイヤ(層)でみるとこうなっています。 f:id:cacao1:20181023222757p:plain 上半分と下半分は、ちょうど鏡に映したような関係になっています。 で、前回は下の図のレイアウトとLayoutProxyの部分のはなしを少し書きました。 今回はその内側のZoneController(基板)とZcProxyの部分について書きます。

ZoneController(基板)側

ZoneControllerのソフトウェアはArduinoで書きました。ただ、Arduinoのメインループにいろいろ書くと、一画面に入らない処理が理解できないおじさんには、あとで保守できなくなるのが見えていたので、フィーダーやスイッチはクラスにして外に出しました。ですのでメインループはこれだけ↓になりました。

//ZoneController(基板)のArduinoのメインループ
void loop() {//メインループ
    if (!AppConfig.isConfigMode()) { loop1(); }//通常モード
    else { AppConfig.process(); }//設定モード
}

void loop1(){//通常モード
    long ctime = millis();
    if (ctime - ms >= 150) {
        //Serial.print("Exec...");
        //Serial.println(ctime);
        String cmds = recieveCmds();
        yield();
        if (isCmds4me(cmds)) {
            String cmdsBody = getCmdsBody(cmds);
            SynchronizerA::DelivCmds(cmdsBody);
            FeederA::DelivCmds(cmdsBody);
            SwitchA::DelivCmds(cmdsBody);
        }
        else {
            Serial.println("not for me " + cmds.substring(20));
        }
        FeederA::ExecCmds();
        String results0 = FeederA::gatherResults();
        String results1 = FeederA::gatherExistence();
        String results2 = SwitchA::gatherResults();
        String results3 = SynchronizerA::gatherResults();
        String result = results0 + results1 + results2 +results3;
        yield();
        ms = ctime;
        long t = millis() - ms;
        //Serial.println(t);
        int v = analogRead(A0);
        //Serial.print("v=");
        //Serial.println(v);
        sendResults(result);
    }
    yield();
}

サーバーとの通信方式はUDPにしました。当初はTCPで、WebSocket>Http>生ソケットと順に試したのですが、ESP8266側でどうしても不定期に遅延が発生してしまいます。ライブラリの中身はおじさんのスキルではとても追えません。困りましたが、考えてみたら状態値はサーバーへ送り返すつもりでした。ですので、このレイヤで到達保証がなくても、返り値をチェックして指定した状態になるまで繰り返し送り続ければ用は足せます。そこで、TCPはやめてUDPにしました。 UDPだと軽快に動きます。が、反面、送信元の偽装もたやすいので、ジャックされるリスクはあります。

ZoneControllerの処理周期は約150msとしています。150msのうちに次の処理をします。

  • コマンド文字列の受信
  • 受信したコマンド文字列を同期処理、フィーダー、スイッチマシン処理クラスへ配布
  • フィーダークラスへ配布したコマンドの実行(方向とDutyの設定)
  • フィーダー、スイッチマシン、の処理結果文字列の収集、在線情報の収集
  • スイッチマシン用コンデンサの充電電圧の取得
  • 返送する結果文字列の組み立て
  • 結果文字列の送信

位相同期やスイッチマシンの切り替えはループの中では行わずに割込み処理の中で行っています。

ZcProxy(サーバー)側

次にこれと対向するサーバー(パソコン)側ソフトです。 サーバー一台で複数のZoneController(基板)を相手にします。従来型のクライアント-サーバー方式的な発想だと、接続相手ごとにスレッドを起こして各スレッドが1台のクライアント(Zoncontroller)を相手にすることになります。仮にZoneControllerが20台あるとすると、サーバー側で20スレッドが必要になります。最近のパソコンなら問題ないとも言えますが、クライアントの数だけスレッドを起こすなんて、ちょっと手法的に残念な感じがします。そこで、現代的にTaskを使うことにしました(※)。Taskは「小さい処理のカタマリを記述するだけで、環境に内蔵されたスケジューラがいい具合に空いているスレッドを使って実行し、別のところで待っていれば実行結果を教えてくれる」というものです(おじさんの理解はこの程度です)。昔はこんなのなかったのですが、便利な世の中になりましたね。

で、構成は次のようになっています。まず、プログラムを起動すると、最初にZoneController(基板)に対応するゾーンコントローラクラスのインスタンスを生成します(ここは設定ファイルを読み込みそのとおりインスタンス化します)。その後、プログラムは、生成されたインスタンスが内包しているフィーダーとスイッチなどの状態値(LastSendValueという名前にしました)を読みだして一つ文字列に編集し、自分の担当するZoneController(基板)に向けて送信する動作を始めます(これはTask)。同様に、ZoneControllerから返ってくる現在の値の文字列をインスタンスのフィーダーやスイッチのLastRespValueに格納する動作もはじめます(これもTask)。送信は300msごと、受信は受信の都度(=ZoneController側の送信間隔の150ms)おこないます(送信はもう少し詰めるつもりです)。 このような構造にすると、サーバー側の上位プログラム(前回のレイアウトプロキシのような)からは、ZoneControllerのハードウェアを直接操作せずとも、ZoneControllerクラスのインスタンスが内包するフィーダーなどのプロパティ(LastSendValue,LastRespValue)を読み書きすれば物理的なゾーンコントローラの状態の取得・設定が行えます(読み出しについてはプロパティの他にRxでのイベント通知もおこなうようにしました)。 というわけで、このライブラリはZoneControllerのプロキシという意味でZcProxyという名前にしました。

(※)なんていいつつ、Taskの中で、"Thread.Sleep(10)"とか書いていたのは内緒です(これだとタスクが自身を実行中のスレッドをブロックしてしまいますので、Taskにした意味がないですね)。

//サーバー側(ZcProxy)の送受信部分
        public static void StartSend() {
            IsSendCanceled = false;
            ZcProxy.Zcs.AsParallel().Select(z => {//Zcごとにタスクを起動する
                var uc = new UdpClient();
                Task.Run(async() => {
                    while (!IsSendCanceled) {
                        if (z.remoteEP == null)
                        {
                            await Task.Delay(200);
                            continue;
                        }
                        var s = z.Id + ':' + z.GatherCmds4Snd();
                        byte[] buf = Encoding.ASCII.GetBytes(s);
                        uc.Connect(z.remoteEP);
                        uc.Send(buf, buf.Length);
                        await Task.Delay(300);
                    }
                    Console.WriteLine("Send Canceled "+z.Id);
                });
                return "";
            }).ToList();
        }

        public static void StartListen() {
            IsListenCanceled = false;
            Task.Run(async () => {
                using (var udpClient = new UdpClient(2012)) {
                    while (!IsListenCanceled) {
                        var rcvResult = await udpClient.ReceiveAsync();
                        string rcvMsg = Encoding.UTF8.GetString(rcvResult.Buffer);
                        var ms = rcvMsg.Split(':');
                        var zc = GetZcByRcvResult(rcvResult.RemoteEndPoint, ms[0]);
                        try {
                            var msg = ms[1];//ex:kf0-s30,kf0-d0....
                            zc.SetRespString(msg);
                        }
                        catch (Exception ex) {
                            Console.Write(".");
                            Console.WriteLine(ex.ToString());
                            Console.WriteLine(rcvMsg);
                        }
                    }
                    Console.WriteLine("Listen Canceled ");

                }
            });
        }

位相同期の顛末

位相同期がまだ調整中ということを第8回で書きましたが、その後解決したので、その報告を書いておきますね(あれから2か所直しました)。

フィーダー電圧検出時のマスク期間の延長

PWMがOffになってから100usでモータードライバの出力がOffになるというBD6231Fの仕様に従ってワンショットマルチで100usだけGPIOのOn期間を延長し、それをフィーダーからの信号のマスクに使っていましたが、設計上で100usピッタリにするとマスクしきれないことがあって(素子に誤差があることを考えれば当たり前ですよね(汗))、意図しない割込みが大量に発生していました。そこで時定数を決めているコンデンサを増量(並列に追加)し、マスクの期間を延ばしたところ、不要な割込みが発生しなくなり安定動作するようになりました。

タイミング調整不可パターンでのタイミングずらし

第5回でいうところの「あなただけがいる」が成立→不成立に変化するケースには2パターンがありますが、このうち、「私」がその原因である場合はどれだけタイミングをずらせばいいか判断できません。従来はこのケースでは何もせずにスルーしていましたが、それだと短時間のうちには同期できませんので、この場合には無条件に90usタイミングを遅らせるようにしました。こうすれば、理屈のうえでは、最大10回(900us)くらい繰り返すうちにどこかでタイミングがはかれるケースに当たり、同期できます。(在線検知のために最低でもduty10%以上で電圧を印加しているため1000usは待たなくていい)

void SynchronizerA::syncB(){
    //同期するかどうかの判定
    FeederA *fd = getFeederByChannel(Channel);
    short targetPort = fd->getAnalogPort();
    short targetCh = fd->getId();
    if (targetCh > 3) { return; }//0-3ch以外なら処理しない
    short duty = fd->getAnalogValue();
    if (duty > 500) { return; }//高速走行中なら同期しない(しなくても目立たないので)


    //同期開始
    detachInterrupt(1);//再入を防ぐため割り込みをやめる
    int isRAISING = digitalRead(1) == 1;
    if (isRAISING) {//あなただけがいる状態から あなたと私がいる状態、誰もいない状態)になるケース
        if (digitalRead(targetPort) == HIGH) {//現在の私のPWM出力がH(あなただけだったところに私が現れたケース)
            up++;
            timer1_disable();
            delayMicroseconds(90);
            timer1_write(1);
            timer1_enable(TIM_DIV1, TIM_EDGE, TIM_SINGLE);
        }
        else {//現在のPWM出力がL(あなただけがいる状態からあなたが去ったケース ★この状態変化を利用する(次にあなたが現れるタイミングに自分を合わせる))
            upA++;
            timer1_disable();
            int duty1024 = static_cast<double>(duty / 1.024);
            delayMicroseconds(1000 - duty1024 - 300 + adj);//実行時にadjに100程度を設定する()
            timer1_write(1);
            timer1_enable(TIM_DIV1, TIM_EDGE, TIM_SINGLE);
        }
    }
    else {//FALLING
        if (digitalRead(targetPort) == LOW) {//誰もいないところにあなたが現れたケースまたは2人いる状態で私が去ったケース ??この状態変化を利用できる?)
            dn++;
        }
        else {//ここには来ない(自分がいる場合はかならず条件を満たさないのでNOT ONLYYOUがLOWではないのでFALLINGではこの条件にはいらない)
        }
    }
label1:
    attachInterrupt(1, syncB, CHANGE);//割り込みを許可する

}