物理エオルゼア時計を作成する(アナログ時計版)

# はじめに
ファイナルファンタジーXIV(FF14)内で流れるエオルゼア時間の時計、現実世界でも欲しいですよね？(強引)
というわけで早速作っていきましょう。

# 実装方法の検討
## 表示方式
### デジタル式
メリット：実装が簡単
デメリット：ゲーム世界観との一体感が低い
### アナログ式
メリット：ゲーム世界との一体感が高い
デメリット：実装がやや困難

→ デジタル式は自身でも[以前に作成した](https://qiita.com/CIB-MC/items/7d60ed998fdf0ce01e68)ので、今回はアナログ式を制作します。

## アナログ式の実装
### 1モーター式
メリット：電子部品点数が少なく済む
デメリット1：機械部品点数が多い
デメリット2：初期調整に時間がかかる(最大で12時間弱分を回転する)

### 2モーター式
メリット1：機械部品点数が少なく済む
メリット2：初期調整時間が短い(時針と分針、それぞれで調整可能)
デメリット：電子部品点数がやや多い

→ 今回は2モーター式を採用します。

## エオルゼア時間の算出方法

### NTPで同期した時刻から算出
メリット1：ネットワーク接続があれば調整が不要
メリット2：現実の時間(日本標準時)を表示させる機能も実装可能
デメリット：ネットワーク接続がないと動作しない

### エオルゼア分ごとに1分進むようモーターを動作させる
メリット：ネットワークがない場所でも動作する
デメリット1：エオルゼア時間専用機になる
デメリット2：時計精度は組み込みボードのそれに依存する

### 現実時間の曜日と時刻を設定して、それを基に算出する
メリット1：ネットワークがない場所でも動作する
メリット2：現実の時間(日本標準時)を表示させる機能も実装可能
デメリット1：初期設定がやや面倒
デメリット2：時計精度は組み込みボードのそれに依存する

→ 今回はNTPで同期した時刻から算出します

# 主な用意したもの
ESP32開発ボード ×1
ステッピングモーター(28BYJ48) ×2
ULN2003A ×2
セラミックコンデンサ 10nF ×1
ユニバーサル基板 ×1
時計ギアボックス(アクリル製) ×1式
時計軸ピン ×1
モーター取付ボルト＋ナット ×4組

## 時計ギアボックス
時計ギアボックスは、オンラインレーザー加工サービスにお願いして作成しました。
ギア比は16歯 → 120歯です。針は120歯の透明アクリルギア自体にレーザー彫刻をして、塗料を流し込みます。
![スクリーンショット 2022-09-20 120107.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2066798/755b112d-5576-abad-b83d-926e266ae37d.png)

## ユニバーサル基板上の配線
下記配線図のようにしました。
※熱に弱い部品は直接実装ではなく、ICソケットやピンヘッダをご利用ください。
※NeoPixelモジュールは無くても時計としては動きます
※12・14番ピンはマイクロスイッチによる自動0時調整用です
　([別記事](https://qiita.com/CIB-MC/items/b7ae1df135b8882dfb8f)にて実装しています)
![81f6c9f130b049fa.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2066798/bddd4dbf-7781-0703-99ea-d5cc747bc38c.png)

# ソフトウェアの実装
Arduinoで組んでいきます。
流れとしては、エオルゼア時を求めてから必要なステップ位置を計算して、前のステップ位置と比較してモーターを動かすようになっています。
場合によっては、逆回転させて速く合うようにしています。
その部分のコードがこちらです。
``` cpp:real_eorzea_clock(抜粋)
int week_sec = (ONE_DAY_SEC * timeinfo.tm_wday) + (ONE_HOUR_SEC * timeinfo.tm_hour) + (60 * timeinfo.tm_min) + timeinfo.tm_sec;
int eorzea_week_sec = (double)week_sec * (1440.0d / 70.0d);

int e_hours = (int)(((double)eorzea_week_sec / (double)ONE_HOUR_SEC ) * 100.0d) % (12 * 100);
int e_minutes = (eorzea_week_sec / 60) % 60;

int r_hours = ((timeinfo.tm_hour % 12) * 100) + (int)(((double)timeinfo.tm_min / 60.0d) * 100.0d);
int r_minuts = timeinfo.tm_min;

int hours_disc_step_c = (int)((double)DISC_STEPS * ((double)(eorzea ? e_hours : r_hours) / (12.0d * 100.0d)));
int minutes_disc_step_c = (int)((double)DISC_STEPS * ((double)(eorzea ? e_minutes : r_minuts) / 60.0d));

int diff_hours = (hours_disc_step_c + DISC_STEPS - hours_disc_step_p) % DISC_STEPS;
int diff_minutes = (minutes_disc_step_c + DISC_STEPS - minutes_disc_step_p) % DISC_STEPS;

if (diff_hours != 0) {
	if (diff_hours < DISC_STEPS_HALF) {
		stepper_hour.step(1);
		hours_disc_step_p = (hours_disc_step_p + 1) % DISC_STEPS;
	} else {
		stepper_hour.step(-1);
		hours_disc_step_p = (hours_disc_step_p + DISC_STEPS - 1) % DISC_STEPS;
	}
}

if (diff_minutes != 0) {
	if (diff_minutes < DISC_STEPS_HALF) {
		stepper_min.step(1);
		minutes_disc_step_p = (minutes_disc_step_p + 1) % DISC_STEPS;
	} else {
		stepper_min.step(-1);
		minutes_disc_step_p = (minutes_disc_step_p + DISC_STEPS - 1) % DISC_STEPS;
	}
}
```

# ソフトウェアの実装(全コード)
<details><summary>長いので折りたたんでいます</summary><div>

``` cpp:real_eorzea_clock
/*
Copyright (c) 2022, CIB-MC
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, 
  this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, 
  this list of conditions and the following disclaimer in the documentation 
  and/or other materials provided with the distribution.
* Neither the name of the <organization> nor the names of its contributors 
  may be used to endorse or promote products derived from this software 
  without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

#include <Adafruit_NeoPixel.h>
#include <Stepper.h>
#include <time.h>
#include <WiFi.h>

const char* ssid       = "YOUR-SSID-HERE";
const char* password   = "YOUR-WIFI-PW-HERE";
const char* ntpServer = "NTP-SERVER-HERE";
const long  gmtOffset_sec = 3600 * 9;
const int   daylightOffset_sec = 0;

const int ONE_DAY_SEC = 60 * 60 * 24;
const int ONE_HOUR_SEC = 60 * 60;
const int DISC_STEPS = 120 * 64 * 2;
const int DISC_STEPS_HALF = 120 * 64;

const int DIN_PIN = 26;
const int LED_COUNT = 4;

const int PIN_START_STOP = 4;
const int PIN_EORZEA_REAL = 0;
const int PIN_HOUR_CW = 15;
const int PIN_MIN_CW = 2;

const int MOTOR_STEPS = 2048;

const int PIN_STP_HOUR_A = 33;
const int PIN_STP_HOUR_B = 32;
const int PIN_STP_HOUR_C = 5;
const int PIN_STP_HOUR_D = 18;

const int PIN_STP_MIN_A = 21;
const int PIN_STP_MIN_B = 22;
const int PIN_STP_MIN_C = 23;
const int PIN_STP_MIN_D = 19;

int buttonState[4];
bool started = false;
bool eorzea = false;

int hours_disc_step_p = 0;
int minutes_disc_step_p = 0;
Adafruit_NeoPixel pixels(LED_COUNT, DIN_PIN, NEO_GRB + NEO_KHZ800);
Stepper stepper_min(MOTOR_STEPS, PIN_STP_MIN_A,PIN_STP_MIN_C,PIN_STP_MIN_B,PIN_STP_MIN_D);
Stepper stepper_hour(MOTOR_STEPS, PIN_STP_HOUR_A,PIN_STP_HOUR_C,PIN_STP_HOUR_B,PIN_STP_HOUR_D);

void setup() {
  Serial.begin(115200);
  pixels.begin();
  pinMode(PIN_START_STOP, INPUT_PULLUP);
  pinMode(PIN_EORZEA_REAL, INPUT_PULLUP);
  pinMode(PIN_HOUR_CW, INPUT_PULLUP);
  pinMode(PIN_MIN_CW, INPUT_PULLUP);
  pixels.clear();
  pixels.setPixelColor(0, pixels.Color(64, 0, 0));
  pixels.setPixelColor(1, pixels.Color(64, 0, 0));
  pixels.setPixelColor(2, pixels.Color(64, 0, 0));
  pixels.setPixelColor(3, pixels.Color(64, 0, 0));
  pixels.show();
  stepper_min.setSpeed(10);
  stepper_hour.setSpeed(10);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
      delay(1000);
      Serial.print(".");
  }
  Serial.println("done");
  xTaskCreatePinnedToCore(repeatingConfigTime, "repeatingConfigTime", 8192, NULL, 2, NULL, 1);
}

void repeatingConfigTime(void *args) {
  while(true) {
    configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
    Serial.print("repeatingConfigTime");
    delay(ONE_DAY_SEC * 1000);
  }
}

void loop() {
  pixels.clear();
  pixels.setPixelColor(0, pixels.Color(64, 64, 64));
  pixels.setPixelColor(1, pixels.Color(64, 64, 64));
  pixels.setPixelColor(2, pixels.Color(64, 64, 64));
  pixels.setPixelColor(3, pixels.Color(64, 64, 64));
  buttonState[0] = digitalRead(PIN_START_STOP);
  buttonState[1] = digitalRead(PIN_EORZEA_REAL);
  buttonState[2] = digitalRead(PIN_HOUR_CW);
  buttonState[3] = digitalRead(PIN_MIN_CW);

  if (!started) {
    pixels.setPixelColor(0, pixels.Color(0, 64, 0));
    pixels.setPixelColor(1, pixels.Color(0, 64, 0));
    pixels.setPixelColor(2, pixels.Color(0, 64, 0));
    pixels.setPixelColor(3, pixels.Color(0, 64, 0));
  } else {
    pixels.setPixelColor(0, pixels.Color(64, 64, 64));
    pixels.setPixelColor(1, pixels.Color(64, 64, 64));
    pixels.setPixelColor(2, pixels.Color(64, 64, 64));
    pixels.setPixelColor(3, pixels.Color(64, 64, 64));
  }
  if (buttonState[0] == LOW) {
    pixels.setPixelColor(2, pixels.Color(0, 0, 64));
  }
  if (buttonState[1] == LOW && buttonState[0] != LOW) {
    pixels.setPixelColor(1, pixels.Color(64, 0, 0));
  }
  if (buttonState[2] == LOW) {
    pixels.setPixelColor(3, pixels.Color(0, 0, 64));
  }
  if (buttonState[3] == LOW && buttonState[2] != LOW) {
    pixels.setPixelColor(0, pixels.Color(64, 0, 0));
  }
  pixels.show();


  if (buttonState[0] == LOW) {
    started = !started;
    delay(1500);
  }
  if (buttonState[1] == LOW) {
    eorzea = !eorzea;
    delay(1500);
  }
  if ((buttonState[2] == LOW) && !started) {
    stepper_hour.step(1);
    hours_disc_step_p = 0;
  }
  if ((buttonState[3] == LOW) && !started) {
    stepper_min.step(1);
    minutes_disc_step_p = 0;
  }

  struct tm timeinfo;
  if (started && getLocalTime(&timeinfo)) {
    int week_sec = (ONE_DAY_SEC * timeinfo.tm_wday) + (ONE_HOUR_SEC * timeinfo.tm_hour) + (60 * timeinfo.tm_min) + timeinfo.tm_sec;
    int eorzea_week_sec = (double)week_sec * (1440.0d / 70.0d);

    int e_hours = (int)(((double)eorzea_week_sec / (double)ONE_HOUR_SEC ) * 100.0d) % (12 * 100);
    int e_minutes = (eorzea_week_sec / 60) % 60;

    int r_hours = ((timeinfo.tm_hour % 12) * 100) + (int)(((double)timeinfo.tm_min / 60.0d) * 100.0d);
    int r_minuts = timeinfo.tm_min;

    int hours_disc_step_c = (int)((double)DISC_STEPS * ((double)(eorzea ? e_hours : r_hours) / (12.0d * 100.0d)));
    int minutes_disc_step_c = (int)((double)DISC_STEPS * ((double)(eorzea ? e_minutes : r_minuts) / 60.0d));

    int diff_hours = (hours_disc_step_c + DISC_STEPS - hours_disc_step_p) % DISC_STEPS;
    int diff_minutes = (minutes_disc_step_c + DISC_STEPS - minutes_disc_step_p) % DISC_STEPS;

    if (diff_hours != 0) {
      if (diff_hours < DISC_STEPS_HALF) {
        stepper_hour.step(1);
        hours_disc_step_p = (hours_disc_step_p + 1) % DISC_STEPS;
      } else {
        stepper_hour.step(-1);
        hours_disc_step_p = (hours_disc_step_p + DISC_STEPS - 1) % DISC_STEPS;
      }
    }

    if (diff_minutes != 0) {
      if (diff_minutes < DISC_STEPS_HALF) {
        stepper_min.step(1);
        minutes_disc_step_p = (minutes_disc_step_p + 1) % DISC_STEPS;
      } else {
        stepper_min.step(-1);
        minutes_disc_step_p = (minutes_disc_step_p + DISC_STEPS - 1) % DISC_STEPS;
      }
    }
  }
}
```
</div></details>

# 完成品紹介動画
<iframe width="560" height="315" src="https://www.youtube.com/embed/t6JghRacPK8" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

# さいごに
引き続き楽しいエオルゼアライフを！