日陰を歩こう Line Things + UVセンサ

#夏ですね

連日暑い日が続きますね。
できればクーラーの効いた部屋の中でじっとしてやり過ごしたいものですが、
そういう訳にもいかず何かしら用をたしに外にでなければならない。
夏場はほんと外を歩くのが億劫になります。

#というわけで

少しでもこの暑い中出歩くしんどさを紛らわしてくれるものが作れないかという思いから、
[Line things](https://developers.line.biz/ja/docs/line-things/about-line-things/)を利用した、日陰歩くゲーム？みたいなのを作ってみました。


#どういうやつか
単純です。
デバイスに接続したUVセンサから紫外線量を取得して、
それを[UVインデックス](https://www.data.jma.go.jp/gmd/env/uvhp/3-50uvindex_manual.html)という紫外線の強さの指標に変換します。
UVインデックスが「中程度」以上であればLIFF上のキャラクターのライフゲージみたいなのが減ってくみたいなのにしました。

>UVインデックスとは紫外線が人体に及ぼす影響の度合いをわかりやすく示すために、紫外線の強さを指標化したもの

らしいです。
５段階になっています。

![kankyo_syo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/415401/4b1e9601-3fb5-1d52-13e2-2516a25e6338.png)

今回は、「できるだけ日陰を利用しよう」となっている中程度以上でゲージがへる、それ以下でゲージが回復するというかたちにしました。

#　こんな感じです

[![デモ動画サムネイル](http://img.youtube.com/vi/6equvf9y0Js/0.jpg)](http://www.youtube.com/watch?v=6equvf9y0Js)

すいません。。退屈ですね。
作ったタイミングで曇りになったので外歩いて試すとかできてないです。

# デバイス

M5stack + [Grove - I2C UV Sensor (VEML6070)](http://wiki.seeedstudio.com/Grove-I2C_UV_Sensor-VEML6070/)


- M5stackのGroveポートに接続
- [Adafruitが出しているライブラリ](https://github.com/adafruit/Adafruit_VEML6070)を利用
- 取得した紫外線量を[こちら](https://www.denshi.club/cookbook/arduino/m5stack/uv.html)の記事を参考にUVインデックスに変換

[Seedの出しているライブラリ](https://github.com/Seeed-Studio/Seeed_VEML6070)を使えばUV Indexまで出してくれるのですが、なぜか常に０を返すので諦めて、Adafruitのライブラリで紫外線量を取得、自前でUV INDEXを返すというかたちにしました。
なぜSeedのライブラリだとうまくいかないのか、、ライブラリで設定されている抵抗の値？がM5Stackの抵抗の値と違うからなのか。。？integration timeって何ですか。。？知識不足でわからず。。
[データシート](https://www.vishay.com/docs/84310/designingveml6070.pdf)読めたら分かるのかもしれないけどさっぱりです。


# 実装

長いので一部のみ載せます。
全文はこちら、
- [M5Stack側](https://github.com/karukade/liff-UV/blob/master/uv.ino)
- [Liff](https://github.com/karukade/liff-UV/blob/master/liff/liff.js)

**M5Stack側（一部）**

```c++
//省略

bool deviceConnected = false;
bool oldDeviceConnected = false;
bool scanUV = false;

Adafruit_VEML6070 uv = Adafruit_VEML6070();


class serverCallbacks: public BLEServerCallbacks {
  void onConnect(BLEServer* pServer) {
   deviceConnected = true;
  };

  void onDisconnect(BLEServer* pServer) {
    deviceConnected = false;
  }
};

class writeCallback: public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic *bleWriteCharacteristic) {
    std::string value = bleWriteCharacteristic->getValue();
    if ((char)value[0] == 1) {
      //省略
      scanUV = true;
    } else if((char)value[0] == 0) {
      //省略
      scanUV = false;
    }
   }
};

void setup(){
  //省略

  // UV センサー初期化
  uv.begin(VEML6070_1_T);
}

int getIndex(int uv_data){
  int UVindex;
  if (uv_data <= 560) {UVindex = 1; //Low
  }else if (uv_data >= 561 && uv_data <= 1120) {UVindex = 2; //Moderate
  }else if (uv_data >= 1121 && uv_data <= 1494) {UVindex = 3; //High
  }else if (uv_data >= 1495 && uv_data <= 2054) {UVindex = 4; //Very High
  }else if (uv_data >= 2055 ) {UVindex = 5; //Extreme
  }
  return UVindex;
}

void loop() {
  // Disconnection
  if (!deviceConnected && oldDeviceConnected) {
    delay(500); // Wait for BLE Stack to be ready
    thingsServer->startAdvertising(); // Restart advertising
    oldDeviceConnected = deviceConnected;
    M5.Lcd.clear(BLACK);
    M5.Lcd.setTextColor(YELLOW);
    M5.Lcd.setTextSize(2);
    M5.Lcd.setCursor(65, 10);
    M5.Lcd.println("Ready to Connect");
  }
  // Connection
  if (deviceConnected && !oldDeviceConnected) {
    oldDeviceConnected = deviceConnected;
    M5.Lcd.clear(BLACK);
    M5.Lcd.setTextColor(GREEN);
    M5.Lcd.setTextSize(2);
    M5.Lcd.setCursor(100, 10);
    M5.Lcd.println("Connected");
  }
  
  if (deviceConnected && scanUV) {
    scanUVStart();
   }
   
   delay(500);
}

void scanUVStart() {
  int uv_data = uv.readUV();
  char index[24];
  char s_uv[24];
  char result[100];
  snprintf(s_uv, 24, "%d", uv_data);
  snprintf(index, 24, "%d", getIndex(uv_data));

  strcat(result, s_uv);
  strcat(result, ",");
  strcat(result, index);

  notifyCharacteristic->setValue(result);
  notifyCharacteristic->notify();
  
  M5.Lcd.clear(WHITE);
  M5.Lcd.setTextColor(BLACK);
  M5.Lcd.setTextSize(4);
  M5.Lcd.setCursor(120, 100);
  M5.Lcd.println(result);
}
//省略
```

**Liff側（一部）**

```js
const uiToggle = document.getElementById('toggle')
const uvLevelMap = ['弱い', '中程度', '強い', 'とても強い', '危険']

let callCounter = 0

class LifeGage {
  constructor() {
    this.container = document.getElementById('tab2')
    this.bar = document.getElementById('js-life-bar')
    this.state = 'default'
    this.uvIndex = 0
    this.timer = null
    this.value = 0
  }

  setUvIndex(index) {
    this.uvIndex = index
  }

  toggle(isStart) {
    if (isStart) {
      this.start()
    } else {
      this.stop()
    }
  }

  start() {
    this.loop()
  }

  stop() {
    clearTimeout(this.timer)
  }

  loop() {
    this.timer = setTimeout(() => {
      if (this.uvIndex > 1) {
        this.decrease()
      } else {
        this.increase()
      }
      this.loop()
    }, 300)
  }

  increase() {
    if (this.value === 0) return
    if (this.value >= 0) {
      this.value = 0
    } else {
      this.value = this.value + 3
    }
    this.moveBar()
  }

  decrease() {
    if (this.value === -100) return
    if (this.value <= -100) {
      this.value = -100
    } else {
      --this.value
    }
    this.moveBar()
  }

  moveBar() {
    this.bar.style.transform = `translateX(${this.value}%)`
    this.switchStateIfNeed()
  }

  switchStateIfNeed() {
    let state
    if (this.value >= -50) {
      state = 'default'
    }
    if (this.value < -50) {
      state = 'danger'
    }
    if (this.state !== state) {
      const cls = state === 'default' ? 'is-def' : 'is-danger'
      const removeCls = this.state === 'default' ? 'is-def' : 'is-danger'
      this.container.classList.remove(removeCls)
      this.container.classList.add(cls)
    }
    this.state = state
  }
}

class UvLevel {
  constructor() {
    this.panel = document.getElementById('tab1')
    this.levelText = document.getElementById('js-ttl-level')
  }

  switchState(index) {
    const cls = `is-level-${index}`
    const levelText = uvLevelMap[--index]

    if (this.current) {
      this.panel.classList.remove(`is-level-${this.current}`)
    }
    this.panel.classList.add(cls)
    this.levelText.textContent = levelText
    this.current = index
  }
}

class Tab {
  constructor(tabList) {
    this.triggers = [...tabList.querySelectorAll('.js-tab-btn')]
    this.init()
  }

  init() {
    this.triggers.forEach(trigger => {
      const target = document.getElementById(trigger.dataset.tab)
      const isActive = trigger.classList.contains('is-active')
      trigger.dataset.tab = target

      if (isActive) {
        this.current = { trigger, target }
      }
      trigger.addEventListener('click', e => {
        if (trigger === this.current.trigger) return
        this.toggle(trigger, target)
      })
    })
  }

  toggle(trigger, target) {
    trigger.classList.add('is-active')
    target.classList.add('is-show')
    this.deactiveCurrent()
    this.current = { trigger, target }
  }

  deactiveCurrent() {
    this.current.trigger.classList.remove('is-active')
    this.current.target.classList.remove('is-show')
  }
}

// -------------- //
// On window load //
// -------------- //

window.onload = () => {
  initializeApp()
}

// -------------- //
// UI functions //
// -------------- //

uiToggle.addEventListener('click', uiToggleHandler)

new Tab(document.getElementById('js-tab-list'))
const lifeGage = new LifeGage()
const uvLevel = new UvLevel()

function makeErrorMsg(errorObj) {
  if (typeof errorObj === 'string' || typeof errorObj === 'number')
    return errorObj
  return errorObj.toString()
}

function uiStatusError(message) {
  uiError.innerText = message
}

function uiToggleDeviceConnected(connected) {
  if (!connected) {
    uiToggleUVScanToggle(false)
  }
}

function uiUpdateValues(uvValues) {
  const index = Number(uvValues[1])
  lifeGage.setUvIndex(index)
  uvLevel.switchState(index)
}

function uiToggleUVScanToggle(state) {
  const label = state ? 'Stop' : 'Start'
  uiToggle.textContent = label
}

function uiToggleHandler(e) {
  const isStart = e.target.textContent === 'Start' ? true : false
  uiToggleUVScanToggle(isStart)
  lifeGage.toggle(isStart)
  liffToggleState(isStart)
}

// -------------- //
// LIFF functions //
// -------------- //

function initializeApp() {
  liff.init(() => initializeLiff(), error => uiStatusError(makeErrorMsg(error)))
}

function initializeLiff() {
  liff
    .initPlugins(['bluetooth'])
    .then(() => {
      liffCheckAvailablityAndDo(() => liffRequestDevice())
    })
    .catch(error => {
      uiStatusError(makeErrorMsg(error))
    })
}

function liffCheckAvailablityAndDo(callbackIfAvailable) {
  // Check Bluetooth availability
  liff.bluetooth
    .getAvailability()
    .then(isAvailable => {
      if (isAvailable) {
        uiToggleDeviceConnected(false)
        callbackIfAvailable()
      } else {
        uiStatusError('Bluetooth not available')
        setTimeout(() => liffCheckAvailablityAndDo(callbackIfAvailable), 10000)
      }
    })
    .catch(error => {
      uiStatusError(makeErrorMsg(error))
    })
}

function liffRequestDevice() {
  liff.bluetooth
    .requestDevice()
    .then(device => {
      liffConnectToDevice(device)
    })
    .catch(error => {
      uiStatusError(makeErrorMsg(error))
    })
}

function liffConnectToDevice(device) {
  device.gatt
    .connect()
    .then(() => {
      // Show status connected
      uiToggleDeviceConnected(true)

      // Get service
      device.gatt
        .getPrimaryService(USER_SERVICE_UUID)
        .then(service => {
          liffGetUserService(service)
        })
        .catch(error => {
          uiStatusError(makeErrorMsg(error), false)
        })
      device.gatt.getPrimaryService(PSDI_SERVICE_UUID).catch(error => {
        uiStatusError(makeErrorMsg(error), false)
      })

      // Device disconnect callback
      const disconnectCallback = () => {
        // Show status disconnected
        uiToggleDeviceConnected(false)

        // Remove disconnect callback
        device.removeEventListener('gattserverdisconnected', disconnectCallback)

        // Try to reconnect
        initializeLiff()
      }

      device.addEventListener('gattserverdisconnected', disconnectCallback)
    })
    .catch(error => {
      uiStatusError(makeErrorMsg(error))
    })
}

function liffGetUserService(service) {
  // Button pressed state
  service
    .getCharacteristic(NOTIFY_CHARACTERISTIC_UUID)
    .then(characteristic => {
      liffGetReadCharacteristic(characteristic)
    })
    .catch(error => {
      uiStatusError(makeErrorMsg(error))
    })

  // Toggle LED
  service
    .getCharacteristic(WRITE_CHARACTERISTIC_UUID)
    .then(characteristic => {
      window.writeCharacteristic = characteristic
    })
    .catch(error => {
      uiStatusError(makeErrorMsg(error))
    })
}

function liffGetReadCharacteristic(characteristic) {
  // Add notification hook for button state
  // (Get notified when button state changes)
  characteristic
    .startNotifications()
    .then(() => {
      characteristic.addEventListener(
        'characteristicvaluechanged',
        liffCharacteristicValueChanged
      )
    })
    .catch(error => {
      uiStatusError(makeErrorMsg(error), false)
    })
}

function liffCharacteristicValueChanged(e) {
  const buff = new Uint8Array(e.target.value.buffer)
  try {
    const val = new TextDecoder().decode(buff).split(',')
    uiUpdateValues(val)
  } catch (e) {
    uiStatusError(e)
  }
}

function liffToggleState(state) {
  // on: 0x01
  // off: 0x00
  window.writeCharacteristic
    .writeValue(state ? new Uint8Array([0x01]) : new Uint8Array([0x00]))
    .catch(error => {
      uiStatusError(makeErrorMsg(error), false)
    })
}

```

# まとめ

ちょうど作ったタイミングで曇りになって、外歩きながら試せなかったので晴れたら試したい。
「うわ！ここ日陰ないじゃんやばい死んじゃう！」みたいに一人で盛り上がれるかな。
暑いの少しは紛れるかな。多分無理。