M5StickCでテレビ視聴時間カウンタをさくっと作る

# はじめに
子供に視聴時間を約束してテレビを見せているのですが、度々時間オーバーになってしまいます。(番組自体の時間は気にしているようですが、録画番組の選択画面やザッピングでダラダラ時間を費やしてしまうクセがあるのが気になります)
自制心だけに任せるのは年齢的に難しいですし、こちらから咎めるのもお互いストレスになり良くないです。表を作って都度記入する方法を試したときは少し上手く行きましたが、面倒になって長続きしませんでした。
この問題を解決すべく、テレビの視聴時間をカウントするデバイスを作りました。
使い始めてまだ数日しか使っていませんが、今のところ意識付けになっているようで、ダラダラ見るのが減ったように思います。

# 概略仕様

- 残り時間は日毎に加算し、日付が変わったらデフォルト値にリセットする
- テレビの電源がついている間、残り時間をカウントダウンして画面に表示する
- 手動で残り時間を増減できる
- 手動でカウントを一時停止できる
- 数十秒の誤差は許容する

ズルをさせない仕組みや強制電源オフなどの仕様も考えることはできますが、家族が使うものなので、あえて作り込まずに信頼関係でカバーします。

# 実物イメージ
![tv_counter.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191697/3788b9ff-0263-d006-9aeb-900fe68a3e5b.jpeg)

# 方法
HDMI端子経由で色々な制御ができるCECという規格がありますので、これを使います。
ちなみに、当初はテレビ電源に連動しているテレビのUSB端子を使うつもりだったのですが、録画中にも連動してしまうため、これは使えませんでした。また、赤外線でリモコン信号を受信する方法も考えられますが、現在の状態が確定できないのでこれも難しさがあります。
CEC通信のarduino用コードは検索すると２つ見つかりましたが、作りのしっかりしていそうな[こちら](https://github.com/floe/CEC)のライブラリをベースに改造して使います。
デバイスは手元にあったM5StickCを使いました。

# 接続方法
CECの通信はCECGND、CEC、5vの3線だけでできます。接続回路例がライブラリのドキュメントにありますので見てみると、なんとなく入力と出力の増幅回路を組み合わせたように見えます。部品点数をもうちょっと減らしたいと思い引き続き[調べると](https://en.m.wikipedia.org/wiki/Consumer_Electronics_Control)、CECはオープンドレイン（オープンコレクタ）接続すればいいことがわかりました。また、[こちら](https://lang-ship.com/blog/work/esp32-pinmode/)によるとESP32はオープンドレイン出力、プルアップ入力が設定できるとあります。したがって、結果的には**下記を直結するだけで動かすことができます**。

*筆者はハードウェアについては趣味程度なので、この内容については確証がありません。接続機器によって相性が悪かったり故障の原因になるかもしれませんので試す方は自己責任でお願いします。詳しい方いたら教えて下さい。*

- CECピンと入力ピン
- CECピンと出力ピン
- CECGNDピンとGNDピン
- 5v ピンと5vピン

M5StickCではこれらをGrove端子にまとめることができますので、かなりスッキリします。
![cable.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191697/a0563017-aae7-7dd6-16bd-90d60165cf3d.jpeg)

電源については配線をまとめやすくするため、テレビのUSB端子から取っています。

# ライブラリの変更
Common.cppがAVR用のコードを含んでいますので、下記に置き換えます

```cpp:Common.cpp
#include <stdlib.h>
#include "Common.h"

#include <Arduino.h>

void DbgPrint(const char* fmt, ...)
{
  char FormatBuffer[128]; 
	va_list args;
	va_start(args, fmt);
  vsprintf(FormatBuffer, fmt, args);
 
  char c;
  char* addr = FormatBuffer;

  while ((c = *addr++))
  {
    Serial.print(c);
  }
}
```

また、前記の回路簡略化に応じて、ピンモードを変える必要があります。また増幅回路を省いたことから論理が反転してしまうので、対応する部分を変更します。


```diff:CEC_Device.cpp
 
 void CEC_Device::Initialize(CEC_DEVICE_TYPE type)
 {
-  pinMode(_out_line, OUTPUT);
-  pinMode( _in_line,  INPUT);
+  pinMode(_out_line, OUTPUT_OPEN_DRAIN);
+  pinMode( _in_line,  INPUT_PULLUP);
 
-  digitalWrite(_out_line, LOW);
+  digitalWrite(_out_line, HIGH);
   delay(200);
 
   CEC_LogicalDevice::Initialize(type);
@@ -42,12 +42,12 @@
 bool CEC_Device::LineState()
 {
   int state = digitalRead(_in_line);
-  return state == LOW;
+  return state == HIGH;
 }
 
 void CEC_Device::SetLineState(bool state)
 {
-  digitalWrite(_out_line, state?LOW:HIGH);
+  digitalWrite(_out_line, state?HIGH:LOW);
   // give enough time for the line to settle before sampling
   // it
   delayMicroseconds(50);
```

# メインarduinoスケッチ
ライブラリのサンプルコードに書き足す形で実装しました。
テレビのUSB電源を使っているため、USB電源が切れたときは視聴時間などの値を保存して電源を落とすようにしています。その他は素直に実装してるだけですので説明は省略します。

```cpp:
#include "CEC_Device.h"
#include <M5StickC.h>
#include <Preferences.h>
#include <string>

#define IN_LINE 32
#define OUT_LINE 33

// ugly macro to do debug printing in the OnReceive method
#define report(X) do { DbgPrint("report " #X "\n"); report ## X (); } while (0)

#define phy1 ((_physicalAddress >> 8) & 0xFF)
#define phy2 ((_physicalAddress >> 0) & 0xFF)

class MyCEC: public CEC_Device {
  public:
    MyCEC(int physAddr): CEC_Device(physAddr,IN_LINE,OUT_LINE) { }
    
    void reportPhysAddr()    { unsigned char frame[4] = { 0x84, phy1, phy2, 0x04 }; TransmitFrame(0x0F,frame,sizeof(frame)); } // report physical address
    void reportStreamState() { unsigned char frame[3] = { 0x82, phy1, phy2 };       TransmitFrame(0x0F,frame,sizeof(frame)); } // report stream state (playing)
    
    void reportPowerState()  { unsigned char frame[2] = { 0x90, 0x00 };             TransmitFrame(0x00,frame,sizeof(frame)); } // report power state (on)
    void reportCECVersion()  { unsigned char frame[2] = { 0x9E, 0x04 };             TransmitFrame(0x00,frame,sizeof(frame)); } // report CEC version (v1.3a)
    
    void reportOSDName()     { unsigned char frame[5] = { 0x47, 'H','T','P','C' };  TransmitFrame(0x00,frame,sizeof(frame)); } // FIXME: name hardcoded
    void reportVendorID()    { unsigned char frame[4] = { 0x87, 0x00, 0xF1, 0x0E }; TransmitFrame(0x00,frame,sizeof(frame)); } // report fake vendor ID
    // TODO: implement menu status query (0x8D) and report (0x8E,0x00)
            
    void OnReceive(int source, int dest, unsigned char* buffer, int count) {
      if (count == 0) return;
      switch (buffer[0]) {
        
        case 0x36: DbgPrint("standby\n"); break;
        
        case 0x83: report(PhysAddr); break;
        case 0x86: if (buffer[1] == phy1 && buffer[2] == phy2)
                   report(StreamState); break;
        
        case 0x8F: report(PowerState); break;
        case 0x9F: report(CECVersion); break;  
        
        case 0x46: report(OSDName);    break;
        case 0x8C: report(VendorID);   break;

        case 0x90:
          tv_is_on = (buffer[1]==0x00);
          if(tv_is_on){
            Serial.println("tv_is_on");
          }else{
            Serial.println("tv_is_off");
          }
          break;
       
        default:CEC_Device::OnReceive(source,dest,buffer,count); break;
      }
    }

    void RequestTVState(){
      // tv電源状態のリクエスト
      unsigned char buffer[2] = { 0x8f, 0x00 }; // 04:90:01(standby) or 04:90:00(on)
      TransmitFrame(0, buffer, 2);
    }
    bool tv_is_on = false;
};

MyCEC device(0x1000);

// タイマー関連変数
enum State {STANDBY, PAUSE, COUNT};
State state;
// 状態保存用
Preferences preferences;
char prev_date_str[9];  //yyyymmdd
char date_str[9];    // yyyymmdd
char remain_str[6];  // seconds
// 時間制限
const int limit = 65*60; // seconds
// 時間制限のしきい値など
const int limit_red = 10*60;
const int limit_orange = 20*60;
const int limit_yellow = 30*60;
const int interval = 5;
// 時刻
int start;
int now;
int prev;
// 残り時間
int remain;

int get_daily_seconds(){
  RTC_TimeTypeDef TimeStruct;
  M5.Rtc.GetTime(&TimeStruct);
  return TimeStruct.Hours*3600+TimeStruct.Minutes*60+TimeStruct.Seconds;
}

void get_date(char* output){
  RTC_DateTypeDef DateStruct;
  M5.Rtc.GetData(&DateStruct);
  sprintf(output, "%04d%02d%02d",DateStruct.Year,DateStruct.Month,DateStruct.Date);  
}

void update_display(){
  if(state == State::STANDBY){
    M5.Axp.ScreenBreath(0);
  }else{
    M5.Axp.ScreenBreath(12);
    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0);
    // 現在時刻
    M5.Lcd.setTextFont(1);
    M5.Lcd.setTextSize(1);
    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.printf("%02d:%02d", (start+now)/3600, ((start+now)/60)%60);
    M5.Lcd.printf("\r\n\r\n");
    // 残り時間
    M5.Lcd.setTextFont(1);
    M5.Lcd.setTextSize(4);
    if(state == State::PAUSE){
      M5.Lcd.setTextColor(NAVY);
    }else{  // State::COUNT
      if(remain <= limit_red){
        M5.Lcd.setTextColor(RED);
      }else if(remain <= limit_orange){
        M5.Lcd.setTextColor(ORANGE);
      }else if(remain <= limit_yellow){
        M5.Lcd.setTextColor(YELLOW);
      }else{
        M5.Lcd.setTextColor(WHITE);
      }
    }
    M5.Lcd.printf("%d", remain/60);
  }
}

void setup()
{  
  M5.begin();
  M5.Lcd.setRotation(0);
  Serial.begin(115200);
  device.Initialize(CEC_LogicalDevice::CDT_PLAYBACK_DEVICE);
  device.RequestTVState();
  // 最終日付と視聴時間の取得
  preferences.begin("TV", true);
  preferences.getString("date", prev_date_str, sizeof(prev_date_str));
  preferences.getString("remain", remain_str, sizeof(remain_str));
  preferences.end();
  // 同一日付の場合カウント継続
  get_date(date_str);
  if(strcmp(date_str, prev_date_str) == 0){
    remain = atoi(remain_str);
  }else{
    remain = limit;
  }
  // 時刻表示用
  start = get_daily_seconds();
  // 初期状態セット
  state = State::STANDBY;
  update_display();
  prev = 0;
}

void loop()
{
  M5.update();

  // 操作
  if(M5.BtnA.wasReleased()){
    if(state == State::PAUSE){
      state = State::COUNT;
      update_display();
    }else if(state == State::COUNT){
      state = State::PAUSE;
      update_display();
    }else{
      // pass
    }
  }
  if(M5.BtnB.wasReleased() && state == State::COUNT){
    remain += 5*60;  // 5分増やす
    update_display();
  }
  if(M5.Axp.GetBtnPress() == 2 && state == State::COUNT){
    remain -= 5*60;  // 5分減らす
    update_display();
  }

  now = millis()/1000;
  if(prev+interval<=now){ // interval秒ごとに実行
    prev = now;
    if(state == State::STANDBY){
      if(device.tv_is_on){
        state = State::COUNT;
      }
    }else{
      if(!device.tv_is_on){
        state = State::STANDBY;
      }
    }
    if(state == State::COUNT){
      remain -= interval;
    }
    update_display();
    // 終了判定
    const bool usb_off = (M5.Axp.GetVBusVoltage() < 4);
    if(usb_off){
      sprintf(remain_str, "%d",remain);
      preferences.begin("TV", false);
      preferences.putString("date", date_str);
      preferences.putString("remain", remain_str);
      preferences.end();
      M5.Axp.PowerOff();
    }
    device.RequestTVState();
  }
  device.Run();
}
```