前編ではAIに頼って、この時計の秒が00リセットするタイミングを調べ、秒合わせ手順と、初期設定時の 年月日・時分を合わせる手順を確立してもらいました。
後編では、基板作成と NTP同期させるプログラミングです。
回路図
最終的な回路図を以下に示します。
ESP32-C3 を使いました。
- 抵抗内蔵LEDを使用しているので、LEDの電流制限抵抗は回路図に出てきません
- (右端の)電池遮断スイッチ回路のLEDは、『遮断時に点灯、通常の通電時は消灯』とするため、LEDの向きが他のスイッチと逆向きになっています。
また、バイパス用スライドスイッチを付けて、(マイコンでの制御無関係に)常時通電できるようにしています - フォトMOSリレーは、手持ちのTLP241Bを使用したが、『極性なし(AC/DC 両用のタイプ)、小信号用、オン抵抗が数Ω〜数十Ω程度のもの』であれば何でも良いです
前編で登場したハードと この回路図は、タクトスイッチの取り付け位置が違います。
前編では、フォトMOSリレーのLED側に付けていましたが、この回路では、フォトMOSリレーのリレー側に付けています。(マイコン未使用時も)時計本体のボタンとして機能させるためです。
ユニバーサル基板に実装
配線イメージを以下に示します。
実際に出来上がったハードの写真を示します。
(時計への取り付けは『仮』状態)
ソフトウェア
1日に4回(0時15分, 6時15分, 12時15分, 18時15分)、SNTP で取得した正確な時刻を基準に、ダイソーのデジタル時計の秒を 00 に合わせます。
秒合わせの8分前に起きて、秒合わせの約2分間の手順を実行後に、WiFiを切断してライトスリープします。省電力化のため、一日のほとんどはスリープしている設計です。
GPIO
| 信 号 | GPIO | 備 考 |
|---|---|---|
| M | 4 | Mボタン |
| U(+) | 3 | △ボタン |
| D(−) | 2 | ▽ボタン |
| S | 1 | スヌーズ ボタン |
| P | 0 | 電池の遮断、HIGH = 導通 / LOW = 切り離し |
| LED onBoard |
8 | 起きている間は点灯、スリープ中は消灯。setup の実行中は 0.5 秒間隔で点滅 |
| BOOT ボタン |
9 |
起きている間: 2秒以上長押しすると setupを実行スリープ中: 押すとすぐ起きる。10分経つと再びスリープ |
setup:後述のシリアルコマンドを参照のこと
Wi-Fi 設定
- 初回または接続に失敗したとき、APモード
ClockSync-XXXX(パスワードなし)を開始する - スマホで接続するとCaptive Portalが開く。SSID とパスワードを保存すると再起動する
-
wifi resetコマンドで設定を消去して再起動する
Captive Portal 画面
スマホのWiFi設定画面でClockSync-XXXXを選択すると、少し遅れて自動的にCaptive Portal 画面が開く。

接続先のSSIDを一覧から選択し、パスワードを入力する。
『保存して再起動』ボタンをタップすると、ESP32のフラッシュに設定値を保存して再起動する。→ 設定したWiFiルータに接続する。

シリアルコマンド
シリアルモニターでESP32に接続すると、状態表示やコマンド入力ができます。
- ボタン操作(
M 1/M 0/U 1/U 0/D 1/D 0/S 1/S 0/P 1/P 0) -
sync:秒合わせ手順を開始 -
ntp:SNTPテスト -
mode:12/24 時間表示の切り替え(デフォルト24H) -
log:同期の記録(ログ)を表示(直近の24イベント) -
log clear:ログクリア -
setup:電源リセットからの年月日時分合わせ手順を開始 -
status:状態表示 -
wifi reset:STAモードのSSID/パスワードをリセットし、APモードで再起動する -
help:次の内容を表示コマンド: M|U|D|S|P 1|0 / sync / ntp / mode / log / log clear / setup / status / wifi reset / help
setup: P 0 で電池をリセット(2017-01-01 12:00 AM)→ 時・分・年・月・日を設定 → 日次の手順で秒を 00 に合わせる(約3分)。事前に、P のバイパス用スライドスイッチを OFF にすること(ON のままだと、時計はリセットされず、設定がずれる)。
同期の記録(ログ)
直近 24 件(時刻・種類・結果・NTP のズレと往復遅延・切り替わり予定)を NVS に保存。log で新しい順に表示、log clear で消去、status に直近の 1 件も表示。
日次の同期手順(前編で検証済みの手順)
分 m の :05 に M 長押し → M(分の設定へ)→ :15/:27/:39/:51 に U→D(正味 0)→ m:59.9 に U を1回。
(m+2):00 ちょうどに次の分へ切り替わる。
(PRESS_TO_FLIP_MS(60100)は実測値。数日の測定で微調整する)
前提: 時計の誤差が −10〜+40 秒以内。m=59 は不可。
前提範囲外での時刻合わせには、setup(電源リセットからの年月日時分合わせ手順)を使用すること。
コード(スケッチ)
制御がしやすい Arduino環境にしました。
次の記事で紹介している、TCPSerial.hとWiFiPortal.hを使用しています。
| ファイル名 | ライン数 | コード数 |
|---|---|---|
| clock_sync.ino | 671 | 602 |
| clock_ctl.h | 436 | 379 |
| config.h | 74 | 44 |
| logts.h | 84 | 72 |
| synclog.h | 118 | 105 |
| 合計 | 1,383 | 1,202 |
(コード数:空行、コメント行を含まない行数)
コードを見る
clock_sync.ino
// 時計合わせ: SNTP で取得した正確な時刻を基準に、時計の秒を 00 に合わせる(ESP32-C3)
// ボタンは GPIO → フォトMOSリレー → 時計の基板。HIGH で「押す」。
#include <WiFi.h>
#include <ESPmDNS.h>
#include <esp_sleep.h>
#include <driver/gpio.h>
#include <WiFiServer.h>
#include <Preferences.h>
#include "config.h"
#include "WiFiPortal.h" // Wi-Fi の設定ポータルと、接続
#include "TCPSerial.h" // Wi-Fi コンソール(nc で接続するシリアル)
#include "clock_ctl.h"
static WiFiPortal portal;
static bool portalWasActive = false; // 前回の loop() でポータルが動いていたか(閉じたことの検出用)
static WiFiServer netServer(NET_CON_PORT);
static TCPSerial SerialTCP(netServer);
static String lineBuf;
static time_t lastSlotDone = 0; // 実行済みのスロット(分の頭)
static bool autoSetup = false; // 1 日 1 回の自動 setup(NVS に保存。初期値は無効)
static long lastSetupYmd = 0; // 最後に自動 setup を実行した日(yyyymmdd。NVS に保存)
static time_t winEndEpoch = 0; // nextWindowStart() が inWin=true を返したときの、窓の終わり
void loadAutoSetup() {
Preferences p;
p.begin("cfg", true);
autoSetup = p.getBool("as", false);
lastSetupYmd = p.getLong("sd", 0);
p.end();
}
void saveAutoSetup() {
Preferences p;
p.begin("cfg", false);
p.putBool("as", autoSetup);
p.putLong("sd", lastSetupYmd);
p.end();
}
// now 以降で、PRESYNC_LEAD_S 秒前に間に合う、最も近いスロットの「分の頭」を返す
time_t nextSlot(time_t now) {
time_t best = 0;
struct tm lt;
localtime_r(&now, <);
for (int d = 0; d <= 1; d++) {
for (int i = 0; i < SYNC_SLOT_COUNT; i++) {
struct tm s = lt;
s.tm_mday += d;
s.tm_hour = SYNC_SLOTS[i][0];
s.tm_min = SYNC_SLOTS[i][1];
s.tm_sec = 0;
time_t ts = mktime(&s);
if (ts - PRESYNC_LEAD_S > now && (best == 0 || ts < best)) best = ts;
}
}
return best;
}
// いま実行すべきスロット(NTP の取り直し〜開始に間に合う窓: スロット -45 秒 〜 -12 秒)を返す。なければ 0
time_t dueSlot(time_t now) {
struct tm lt;
localtime_r(&now, <);
for (int d = 0; d <= 1; d++) {
for (int i = 0; i < SYNC_SLOT_COUNT; i++) {
struct tm s = lt;
s.tm_mday += d;
s.tm_hour = SYNC_SLOTS[i][0];
s.tm_min = SYNC_SLOTS[i][1];
s.tm_sec = 0;
time_t ts = mktime(&s);
if (now >= ts - PRESYNC_LEAD_S && now <= ts - 12) return ts;
}
}
return 0;
}
void printSleepStatus(); // 省電力の節で定義
void printNetStatus(); // Wi-Fi コンソールの節で定義
void printStatus() {
{
String list;
for (int i = 0; i < portal.savedCount(); i++) list += String(i ? ", " : "") + "\"" + portal.savedSsid(i) + "\"";
logf("[status] 保存済み SSID=%s 接続中=\"%s\" portal=%d\r\n", list.c_str(),
WiFi.status() == WL_CONNECTED ? WiFi.SSID().c_str() : "-", portal.portalActive());
}
struct timeval tv;
gettimeofday(&tv, nullptr);
struct tm lt;
localtime_r(&tv.tv_sec, <);
logf("[status] %04d-%02d-%02d %02d:%02d:%02d.%03ld valid=%d wifi=%s ip=%s rssi=%d\r\n",
lt.tm_year + 1900, lt.tm_mon + 1, lt.tm_mday, lt.tm_hour, lt.tm_min, lt.tm_sec,
(long)(tv.tv_usec / 1000), clockctl::timeIsValid(),
WiFi.status() == WL_CONNECTED ? "connected" : "down",
WiFi.localIP().toString().c_str(), WiFi.RSSI());
synclog::print(1); // 直近の同期の記録
time_t n = clockctl::timeIsValid() ? nextSlot(time(nullptr)) : 0;
if (n) {
struct tm s;
localtime_r(&n, &s);
logf("[status] 次の同期スロット %02d:%02d\r\n", s.tm_hour, s.tm_min);
}
logf("[status] pf=%d ms(最後の U → 切り替わり)\r\n", clockctl::pressToFlipMs);
printNetStatus();
logf("[status] autosetup=%s (毎日 %02d:%02d、最後の実行日 %ld)\r\n", autoSetup ? "on" : "off", SETUP_SLOT_H, SETUP_SLOT_M, lastSetupYmd);
printSleepStatus();
}
static uint32_t lastNtpMs = 0; // 直近の NTP 取得の時刻(millis)
static bool echoOn = true; // シリアルで入力した文字を、エコーバックする
// ---- 省電力(ライトスリープ) ----
static uint32_t lastActivityMs = 0; // 起動、シリアル入力、BOOT 起床の時刻(この後 ACTIVE_GRACE_MS は眠らない)
static bool bootLongFromSleep = false; // スリープから BOOT で起きた直後に、長押しを検出した
static uint32_t bootDownSince = 0; // BOOT ボタンが押され始めた時刻
static bool bootFired = false; // この押下では、長押しを処理済み
static bool sleepEnabled = AUTO_SLEEP;
static uint32_t lastReconnectTry = 0;
// now を含む同期の窓 [スロット - WAKE_LEAD_S, スロット + SYNC_TAIL_S] があれば inWin=true。
// なければ、次の窓の開始時刻(エポック秒)を返す
time_t nextWindowStart(time_t now, bool& inWin) {
inWin = false;
time_t best = 0;
struct tm lt;
localtime_r(&now, <);
// 窓の候補: 同期スロット(+ 自動 setup が有効なら、setup のスロット)
const int nslots = SYNC_SLOT_COUNT + (autoSetup ? 1 : 0);
for (int d = -1; d <= 1; d++) {
for (int i = 0; i < nslots; i++) {
const bool isSetup = (i == SYNC_SLOT_COUNT);
struct tm s = lt;
s.tm_mday += d;
s.tm_hour = isSetup ? SETUP_SLOT_H : SYNC_SLOTS[i][0];
s.tm_min = isSetup ? SETUP_SLOT_M : SYNC_SLOTS[i][1];
s.tm_sec = 0;
time_t ts = mktime(&s);
time_t start = ts - WAKE_LEAD_S, end = ts + (isSetup ? SETUP_TAIL_S : SYNC_TAIL_S);
if (now >= start && now <= end) { inWin = true; winEndEpoch = end; return start; }
if (start > now && (best == 0 || start < best)) best = start;
}
}
return best;
}
// いま実行すべき自動 setup(スロットの時刻 〜 +90 秒)のスロット時刻。なければ 0。同じ日に、2 回は、実行しない
long ymdOf(time_t t) {
struct tm lt;
localtime_r(&t, <);
return (lt.tm_year + 1900) * 10000L + (lt.tm_mon + 1) * 100L + lt.tm_mday;
}
time_t dueSetup(time_t now) {
if (!autoSetup) return 0;
struct tm lt;
localtime_r(&now, <);
lt.tm_hour = SETUP_SLOT_H;
lt.tm_min = SETUP_SLOT_M;
lt.tm_sec = 0;
time_t ts = mktime(<);
if (now >= ts && now <= ts + 90 && ymdOf(ts) != lastSetupYmd) return ts;
return 0;
}
void lightSleepFor(uint32_t ms) {
Serial.flush();
clockctl::ledOn = false; // スリープ中は LED 消灯
digitalWrite(PIN_LED, HIGH);
gpio_hold_en((gpio_num_t)PIN_LED);
gpio_hold_en((gpio_num_t)PIN_P); // P(電池)は、スリープ中も HIGH のまま
esp_sleep_enable_timer_wakeup((uint64_t)ms * 1000ULL);
gpio_wakeup_enable((gpio_num_t)PIN_BOOT, GPIO_INTR_LOW_LEVEL); // BOOT ボタンで起きる
esp_sleep_enable_gpio_wakeup();
esp_light_sleep_start();
gpio_hold_dis((gpio_num_t)PIN_P);
gpio_hold_dis((gpio_num_t)PIN_LED);
if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_GPIO) {
lastActivityMs = millis();
clockctl::ledOn = true; // 起きたら、すぐ点灯(起きたことの合図)
// 起きた直後に、BOOT が BOOT_HOLD_MS の間、押され続けるかを見る(長押し)。
// この後の Wi-Fi 再接続で、loop() がしばらくブロックされ、ボタンの判定が、間に合わなくなるため。
uint32_t t = millis();
while (digitalRead(PIN_BOOT) == LOW && millis() - t < BOOT_HOLD_MS) delay(10);
if (digitalRead(PIN_BOOT) == LOW) {
bootLongFromSleep = true;
bootFired = true; // 押されたままでも、loop() 側で、二重に処理しない
bootDownSince = millis();
clockctl::ledBlink = true; // 点滅で、長押しを認識したことを知らせる(指を離してよい)
}
}
}
void netConsoleStop(); // Wi-Fi コンソールの節で定義
void wifiOff() {
netConsoleStop(); // Wi-Fi を切る前に、コンソールを閉じる
if (WiFi.getMode() == WIFI_OFF) return;
WiFi.disconnect(true, false);
WiFi.mode(WIFI_OFF);
}
// 保存済みの Wi-Fi に接続する(ポータルは開かない)。省電力(setSleep)は、接続のたびに入れ直す
bool staConnect() {
if (!portal.connect(STA_TIMEOUT_MS)) return false;
WiFi.setSleep(true); // 省電力(発熱を抑える)。NTP の間だけ、ntpSync() が切る
logf("[wifi] 接続: %s\r\n", WiFi.localIP().toString().c_str());
return true;
}
// 操作の直後(猶予の間)は、Wi-Fi の省電力を切る(nc などの、接続が、すぐ、つながるように)。
// 省電力のままだと、最初のパケットが、落ちたり、数百 ms〜1 秒、遅れたりする。猶予が切れたら、省電力に戻す
static void applyWifiPowerSave() {
if (WiFi.getMode() == WIFI_OFF || WiFi.status() != WL_CONNECTED) return;
const bool fast = (millis() - lastActivityMs < ACTIVE_GRACE_MS);
if (WiFi.getSleep() == fast) WiFi.setSleep(!fast); // fast なら、省電力を切る(setSleep(false))
}
// 眠ってよい間は、Wi-Fi を切ってライトスリープ。起きている間は、Wi-Fi を保つ
void powerManage() {
time_t now = time(nullptr);
bool inWin = false;
time_t winStart = clockctl::timeIsValid() ? nextWindowStart(now, inWin) : 0;
bool keepAwake = !sleepEnabled || portal.portalActive() || !clockctl::timeIsValid() || inWin ||
(millis() - lastActivityMs < ACTIVE_GRACE_MS);
if (keepAwake) {
clockctl::ledOn = true; // 起きている間は点灯
applyWifiPowerSave();
if (WiFi.getMode() == WIFI_OFF && !portal.portalActive() && millis() - lastReconnectTry > 30000) {
lastReconnectTry = millis();
logln("[power] 起床。Wi-Fi に接続します");
if (staConnect()) lastNtpMs = millis() - 16UL * 60UL * 1000UL; // 接続したら、すぐ NTP を取り直す
}
return;
}
if (WiFi.getMode() != WIFI_OFF) {
struct tm w;
time_t ws = winStart;
localtime_r(&ws, &w);
logf("[power] Wi-Fi を切り、ライトスリープに入ります(次の起床 %02d:%02d、BOOT 短押しでも起きます)\r\n", w.tm_hour, w.tm_min);
delay(50);
wifiOff();
}
uint32_t ms = 1000;
if (winStart > now) ms = (uint32_t)min<time_t>((winStart - now) * 1000, 60000);
if (ms < 1000) ms = 1000;
lightSleepFor(ms);
}
// status 用: 自動スリープの有効/無効と、スリープまでの残り秒数
void printSleepStatus() {
if (!sleepEnabled) {
logln("[status] sleep=off (自動スリープ無効。起きたまま)");
return;
}
if (portal.portalActive()) {
logln("[status] sleep=on (Wi-Fi 設定ポータル中のため、眠らない)");
return;
}
if (!clockctl::timeIsValid()) {
logln("[status] sleep=on (時刻が未取得のため、眠らない)");
return;
}
time_t now = time(nullptr);
bool inWin = false;
time_t ws = nextWindowStart(now, inWin);
uint32_t el = millis() - lastActivityMs;
long graceLeft = el < ACTIVE_GRACE_MS ? (long)((ACTIVE_GRACE_MS - el) / 1000) : 0;
long winLeft = 0;
time_t nextWake = ws;
if (inWin) {
winLeft = (long)(winEndEpoch - now); // 窓の終了まで
bool dummy;
nextWake = nextWindowStart(now + winLeft + 1, dummy); // 窓の後の、次の起床
}
long left = max(graceLeft, winLeft);
struct tm w;
localtime_r(&nextWake, &w);
if (left > 0 && winLeft > 0) { // 同期の窓の中: 窓の終わりまでの秒数も、あわせて表示
logf("[status] sleep=on あと %ld 秒でスリープ(猶予 %ld 秒 / 同期の窓 %ld 秒)。次の起床 %02d:%02d\r\n", left, graceLeft, winLeft, w.tm_hour, w.tm_min);
} else if (left > 0) {
logf("[status] sleep=on あと %ld 秒でスリープ。次の起床 %02d:%02d\r\n", left, w.tm_hour, w.tm_min);
} else {
logf("[status] sleep=on すぐにスリープできる状態(次の起床 %02d:%02d)\r\n", w.tm_hour, w.tm_min);
}
}
// ---- Wi-Fi コンソール(TCPSerial。PIN 認証。起きていて、Wi-Fi がつながっている間だけ) ----
void handleCommand(String c); // 前方宣言
static bool netStarted = false;
static bool fromNet = false; // 実行中のコマンドが、ネットワーク経由か(pin は、シリアルからのみ)
static String netPin; // NVS に保存。空 = Wi-Fi コンソールは無効
void loadNetPin() {
Preferences p;
p.begin("cfg", true);
netPin = p.getString("pin", "");
p.end();
}
void saveNetPin(const String& pin) {
Preferences p;
p.begin("cfg", false);
if (pin.length()) p.putString("pin", pin); else p.remove("pin");
p.end();
netPin = pin;
}
void netConsoleStop() {
SerialTCP.disconnect();
if (netStarted) {
netServer.end();
MDNS.end();
netStarted = false;
}
}
// 認証が済んだとき(PIN 未設定のときは、接続したとき)
static void netOnConnect(TCPSerial& s) {
s.print("clocksync コンソール。help でコマンド一覧。quit で切断\r\n");
lastActivityMs = millis();
logln("[net] クライアントが認証しました");
}
void netConsoleLoop() {
if (netPin.length() == 0) { // PIN 未設定: 無効
if (netStarted) netConsoleStop();
return;
}
if (WiFi.status() != WL_CONNECTED || portal.portalActive()) return;
if (!netStarted) {
netServer.begin();
SerialTCP.setPin(netPin.c_str()); // 3 回続けて間違えると切断し、5 秒間は受け付けない
SerialTCP.setIdleTimeoutMs(NET_CON_IDLE_MS);
SerialTCP.setOnConnect(netOnConnect);
SerialTCP.begin();
out.net = &SerialTCP; // これ以降のログを、認証済みのクライアントにも出す
MDNS.begin(NET_CON_HOST);
netStarted = true;
logf("[net] Wi-Fi コンソールを開始(ポート %u、%s.local)\r\n", (unsigned)NET_CON_PORT, NET_CON_HOST);
}
SerialTCP.update();
if (SerialTCP.connected()) lastActivityMs = millis(); // 接続中は、眠らない
String line;
while (SerialTCP.readLine(line)) {
line.trim();
if (line.length() == 0) continue;
if (line == "quit" || line == "exit") {
SerialTCP.print("切断します\r\n");
SerialTCP.flush();
SerialTCP.disconnect();
break;
}
fromNet = true;
handleCommand(line);
fromNet = false;
}
}
void printNetStatus() {
logf("[status] netcon=%s (ポート %u、クライアント %s)\r\n", netPin.length() ? (netStarted ? "on" : "待機") : "off(pin 未設定)",
(unsigned)NET_CON_PORT, SerialTCP.connected() ? "認証済み" : "なし");
}
void handleCommand(String c) {
c.trim();
if (c.length() == 0) return;
String raw = c;
c.toLowerCase(); // 大文字・小文字を区別しない
if (clockctl::isBusy()) { // setup / sync の実行中は、表示だけのコマンドを受け付ける
bool ro = (c == "status" || c == "log" || c == "help" || c == "pf" || c == "pin" || c == "autosetup" || c == "wifi list");
if (!ro) {
logf("[busy] 実行中のため、受け付けません: %s\r\n", raw.c_str());
return;
}
}
if (c.length() == 3 && c[1] == ' ' && (c[2] == '0' || c[2] == '1')) { // 例: "M 1" / "P 0"
int pin = clockctl::pinOf(toupper(c[0]));
if (pin >= 0) {
digitalWrite(pin, c[2] == '1' ? HIGH : LOW);
out.println(c + "\r\nok!");
return;
}
}
if (c == "sync") { // 今すぐ(次の分の頭から)日次手順を実行
time_t now = time(nullptr);
time_t m = now - now % 60 + 60;
while (m - now < 40) m += 60; // NTP 取得に最大 30 秒かかるため余裕を持つ
struct tm lt;
localtime_r(&m, <);
if (lt.tm_min == 59) m += 60;
clockctl::runDailySync(m);
} else if (c == "pin" || c.startsWith("pin ")) { // Wi-Fi コンソールの PIN(シリアルからのみ設定できる)
String arg = c.length() > 3 ? c.substring(4) : "";
arg.trim();
if (arg.length() == 0) {
logf("[pin] %s\r\n", netPin.length() ? "設定済み(Wi-Fi コンソール: 有効)" : "未設定(Wi-Fi コンソール: 無効)");
} else if (fromNet) {
logln("[pin] PIN は、シリアルからのみ、変更できます");
} else if (arg == "off" || arg == "clear") {
saveNetPin("");
netConsoleStop();
logln("[pin] PIN を消去しました(Wi-Fi コンソール: 無効)");
} else if (arg.length() < 4 || arg.length() > 16) {
logln("[pin] 4〜16 文字で指定してください(例: pin 4829)");
} else {
saveNetPin(arg);
netConsoleStop(); // 新しい PIN で、接続し直す
logln("[pin] PIN を設定しました(Wi-Fi コンソール: 有効。接続: nc clocksync.local 2323 → PIN を入力)");
}
} else if (c == "autosetup" || c.startsWith("autosetup ")) {
String arg = c.length() > 9 ? c.substring(10) : "";
arg.trim();
if (arg == "on") {
autoSetup = true;
saveAutoSetup();
logf("[autosetup] 有効にしました(毎日 %02d:%02d。P のスライドスイッチは、常に OFF にしてください)\r\n", SETUP_SLOT_H, SETUP_SLOT_M);
} else if (arg == "off") {
autoSetup = false;
saveAutoSetup();
logln("[autosetup] 無効にしました");
} else {
logf("[autosetup] %s (毎日 %02d:%02d、最後の実行日 %ld)\r\n", autoSetup ? "on" : "off", SETUP_SLOT_H, SETUP_SLOT_M, lastSetupYmd);
}
} else if (c == "pf" || c.startsWith("pf ")) { // 最後の U → 切り替わりの補正値 [ms]
String arg = c.length() > 2 ? c.substring(3) : "";
arg.trim();
if (arg.length() == 0) {
logf("[pf] 最後の U から切り替わりまで %d ms(初期値 %d)\r\n", clockctl::pressToFlipMs, PRESS_TO_FLIP_MS);
} else if (arg == "reset") {
clockctl::savePressToFlip(PRESS_TO_FLIP_MS);
logf("[pf] 初期値 %d ms に戻しました\r\n", clockctl::pressToFlipMs);
} else {
int v = arg.toInt();
if (v < 59000 || v > 61000) {
logln("[pf] 59000〜61000 の ms で指定してください(例: pf 59460)");
} else {
clockctl::savePressToFlip(v);
logf("[pf] %d ms に設定しました(保存済み。次の同期から有効)\r\n", clockctl::pressToFlipMs);
}
}
} else if (c == "echo on" || c == "echo off") {
echoOn = (c == "echo on");
logf("[echo] 入力のエコーバックを %s にしました\r\n", echoOn ? "on" : "off");
} else if (c == "sleep off") {
sleepEnabled = false;
logln("[power] 自動スリープを無効にしました(再起動まで)");
} else if (c == "sleep on") {
sleepEnabled = true;
lastActivityMs = millis();
logf("[power] 自動スリープを有効にしました(%lu 秒後から眠れます)\r\n", (unsigned long)(ACTIVE_GRACE_MS / 1000));
} else if (c.startsWith("sleeptest")) { // sleeptest [秒]: Wi-Fi を切って、その秒数だけライトスリープし、復帰を確認
int sec = c.length() > 10 ? c.substring(10).toInt() : 20;
if (sec < 3) sec = 20;
logf("[power] テスト: Wi-Fi を切り、%d 秒間ライトスリープします\r\n", sec);
delay(100);
wifiOff();
uint32_t t0 = millis();
lightSleepFor((uint32_t)sec * 1000UL);
logf("[power] 起床(スリープ %.1f 秒、原因=%d)\r\n", (millis() - t0) / 1000.0, (int)esp_sleep_get_wakeup_cause());
lastActivityMs = millis();
clockctl::ledOn = true;
lastReconnectTry = 0; // loop が Wi-Fi に再接続する
} else if (c == "net") { // ネットワークの診断
logf("[net] IP=%s GW=%s DNS=%s / %s\r\n", WiFi.localIP().toString().c_str(), WiFi.gatewayIP().toString().c_str(),
WiFi.dnsIP(0).toString().c_str(), WiFi.dnsIP(1).toString().c_str());
const char* hs[] = { NTP_SERVER1, NTP_SERVER2, "example.com" };
for (const char* h : hs) {
IPAddress r;
bool ok = WiFi.hostByName(h, r);
logf("[net] 名前解決 %s -> %s (%s)\r\n", h, r.toString().c_str(), ok ? ((uint32_t)r ? "成功" : "0.0.0.0=失敗") : "失敗");
}
} else if (c == "mode") { // 通常表示で U 長押し = 12/24 時間表示の切り替え
clockctl::pressAtUs(PIN_U, clockctl::nowUs(), HOLD_U_LONG_MS);
logln("[mode] 12/24 時間表示を切り替えました");
} else if (c == "ntp") { // 自前 SNTP を試す
clockctl::ntpSync(30000);
} else if (c == "setup") {// 電池リセット → 時・分・年月日 → 秒を 00 に(スライドスイッチ OFF が前提)
clockctl::runFullSetup();
} else if (c == "log") {
synclog::print();
} else if (c == "log clear") {
synclog::clear();
logln("[log] 記録を消去しました");
} else if (c == "status") {
printStatus();
} else if (c == "wifi list") { // 保存済みの Wi-Fi(パスワードは、表示しない)
int nn = portal.savedCount();
for (int i = 0; i < nn; i++) logf("[wifi] 保存 %d: \"%s\"\r\n", i + 1, portal.savedSsid(i).c_str());
if (!nn) logln("[wifi] 保存された Wi-Fi はありません");
} else if (c == "wifi reset") {
portal.clear();
logln("[wifi] Wi-Fi 設定を消去。再起動します");
delay(300);
ESP.restart();
} else if (c == "help") {
out.println("コマンド: M|U|D|S|P 1|0 / sync / ntp / net / wifi list|reset / autosetup [on|off] / pin [PIN|off] / pf [ms|reset] / echo on|off / sleep on|off / sleeptest [秒] / mode / log / log clear / setup(BOOT ボタン長押しでも実行) / status / wifi reset / help");
} else {
out.println("? '" + raw + "' は不明です。help を参照");
}
}
// シリアルの入力を処理する(1 文字ずつエコーバック、Enter でコマンドを実行)
void serialLoop() {
while (Serial.available()) {
char ch = Serial.read();
static bool lastCR = false; // 直前が CR か(CR+LF の LF を、読み飛ばすため)
static int escSkip = 0; // ESC シーケンス(矢印キーなど)の残りを読み飛ばす
if (escSkip > 0) { escSkip--; continue; }
if (ch == 0x1B) { escSkip = 2; continue; }
if (ch == '\r' || ch == '\n') { // LF でも CR でも(screen の Enter は CR)、コマンドを実行
if (ch == '\n' && lastCR) { lastCR = false; continue; }
lastCR = (ch == '\r');
if (echoOn) Serial.print("\r\n");
if (lineBuf.length()) {
String cmdLine = lineBuf;
lineBuf = ""; // 実行中に、受け付けの処理が、再入しても、入力が、混ざらないよう、先に、空にする
String lc = cmdLine;
lc.trim();
lc.toLowerCase();
// status / log / help は、読み取り専用。「操作」とみなさず、スリープまでの猶予をリセットしない
if (!(lc == "status" || lc == "log" || lc == "help")) lastActivityMs = millis();
handleCommand(cmdLine);
}
lineBuf = "";
} else if (ch == 0x08 || ch == 0x7F) { // Backspace / Delete
lastCR = false;
if (lineBuf.length()) {
lineBuf.remove(lineBuf.length() - 1);
if (echoOn) Serial.print("\b \b");
}
} else if ((uint8_t)ch >= 0x20) {
lastCR = false;
lineBuf += ch;
if (echoOn) Serial.write(ch); // 1 文字ずつ、エコーバック
}
}
}
// setup / sync の待ち時間の合間に呼ばれる: シリアルと、Wi-Fi コンソールの入力を処理する
// (実行中は、handleCommand が、読み取り専用のコマンド以外を、拒否する)
static void serviceBusy() {
serialLoop();
netConsoleLoop();
}
// 時刻の取得は、すべて自前の ntpSync で行う(標準の SNTP は、DNS の後始末が、別スレッドと競合して異常終了するため、使わない)
// 通常動作の開始: 最初の時刻を取得する
void startNormalMode() {
for (int i = 0; i < 3 && !clockctl::timeIsValid(); i++) clockctl::ntpSync(15000);
lastNtpMs = millis();
printStatus();
}
// 定期的に時刻を取り直す(15 分おき。同期スロットの直前は、同期の手順の中で取るので、避ける)
void maintainTime() {
if (WiFi.status() != WL_CONNECTED) return;
uint32_t interval = clockctl::timeIsValid() ? 15UL * 60UL * 1000UL : 10UL * 1000UL;
if (millis() - lastNtpMs < interval) return;
time_t now = time(nullptr);
time_t n = clockctl::timeIsValid() ? nextSlot(now) : 0;
if (n && n - now < 180) return; // スロットの 3 分前からは、取り直さない
lastNtpMs = millis();
clockctl::ntpSync(15000);
}
void setup() {
setCpuFrequencyMhz(CPU_MHZ); // 発熱を抑える
clockctl::initPins(); // 最初に、電池を導通(P=HIGH)にしておく
pinMode(PIN_BOOT, INPUT_PULLUP);
clockctl::loadPressToFlip(); // 保存済みの補正値(pf)を読み込む
loadAutoSetup(); // 自動 setup の設定を読み込む
loadNetPin(); // Wi-Fi コンソールの PIN を読み込む
setenv("TZ", TZ_JST, 1); // 接続の成否に関わらず、表示は JST にする
tzset();
Serial.begin(115200);
Serial.setTxTimeoutMs(0); // USB に、読む側が、いないとき、書き込みで、待たない(捨てる)
clockctl::idleHook = serviceBusy; // setup / sync の待ち時間に、コマンドを受け付ける
delay(500);
lastActivityMs = millis();
logln("[boot] clock_sync");
portal.setLog(&libLog); // ライブラリのログにも、時刻を付ける
portal.setNamespace("wifi"); // clock_sync の NVS 領域
portal.setTitle("時計合わせ Wi-Fi 設定");
portal.setApName("ClockSync-" + portal.apName().substring(6)); // 既定の "ESP32-XXXX" の XXXX を使う
portal.setApPassword(AP_PASSWORD);
if (!portal.begin(STA_TIMEOUT_MS)) { // 失敗したら、ポータルを開く(loop() でポータルを動かす)
logln("[wifi] 接続できません。設定ポータルを開始しました");
portalWasActive = true;
return;
}
WiFi.setSleep(true); // 省電力(発熱を抑える)。NTP の間だけ、ntpSync() が切る
logf("[wifi] 接続: %s\r\n", WiFi.localIP().toString().c_str());
startNormalMode();
}
// BOOT ボタン(GPIO9=LOW)を BOOT_HOLD_MS 以上押し続けたら true(1回押すごとに1回だけ)
bool bootButtonLongPress() {
if (millis() < BOOT_IGNORE_MS) return false;
if (digitalRead(PIN_BOOT) == HIGH) { // 離された
bootDownSince = 0;
bootFired = false;
return false;
}
if (bootDownSince == 0) bootDownSince = millis();
if (!bootFired && millis() - bootDownSince >= BOOT_HOLD_MS) {
bootFired = true;
return true;
}
return false;
}
void loop() {
portal.loop();
// ポータル中でも、保存済みの設定で Wi-Fi に接続できたら、ライブラリがポータルを閉じる。通常動作に進む
if (portalWasActive && !portal.portalActive()) {
WiFi.setSleep(true);
logln("[wifi] 接続できたので、ポータルを終了して通常動作に入ります");
startNormalMode();
}
portalWasActive = portal.portalActive();
serialLoop();
netConsoleLoop();
if (portal.portalActive()) {
delay(2);
return;
}
if (bootLongFromSleep || bootButtonLongPress()) {
bool fromSleep = bootLongFromSleep;
bootLongFromSleep = false;
logf("[boot-button] 長押しを検出(%s)。電池リセットからの時刻合わせ(setup)を開始します\r\n", fromSleep ? "スリープ明け" : "通常時");
if (WiFi.status() != WL_CONNECTED) { // スリープ明けは、Wi-Fi が切れているので、先に接続する
staConnect();
}
clockctl::runFullSetup();
clockctl::ledBlink = false; // スリープ明けの長押しで先に点灯した点滅を、確実に止める
}
if (WiFi.status() != WL_CONNECTED) { // 切れたら再接続
static uint32_t last = 0;
if (millis() - last > 30000) {
last = millis();
WiFi.reconnect();
}
}
powerManage();
maintainTime();
if (clockctl::timeIsValid()) {
time_t now = time(nullptr);
time_t su = dueSetup(now);
if (su) { // 1 日 1 回の自動 setup(電池リセットからの時刻合わせ)
lastSetupYmd = ymdOf(su);
saveAutoSetup();
logln("[autosetup] 毎日の setup を開始します");
clockctl::runFullSetup();
lastActivityMs = millis();
}
time_t slot = dueSlot(now);
if (slot && slot != lastSlotDone) {
lastSlotDone = slot;
clockctl::runDailySync(slot, synclog::KIND_AUTO);
}
}
delay(LOOP_DELAY_MS);
}
clock_ctl.h
#pragma once
// 時計のボタン操作と、日次の「秒を 00 に合わせる」手順。
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <sys/time.h>
#include <time.h>
#include <Preferences.h>
#include "config.h"
#include "synclog.h"
namespace clockctl {
// setup / sync の実行中は、コマンドを受け付ける処理を、待ち時間の合間に挟む(読み取り専用のコマンドだけ、受け付ける)
inline volatile int busyCount = 0;
inline void (*idleHook)() = nullptr;
inline bool isBusy() { return busyCount > 0; }
struct BusyScope { // スコープの間だけ「実行中」(入れ子でも、外側が終わるまで、実行中)
BusyScope() { busyCount++; }
~BusyScope() { busyCount--; }
};
// 最後の U の押下 → 次の分への切り替わり [ms]。実測で調整できるように、NVS に保存する(pf コマンド)
int pressToFlipMs = PRESS_TO_FLIP_MS;
void loadPressToFlip() {
Preferences p;
p.begin("cfg", true);
pressToFlipMs = p.getInt("pf", PRESS_TO_FLIP_MS);
p.end();
}
void savePressToFlip(int ms) {
Preferences p;
p.begin("cfg", false);
if (ms == PRESS_TO_FLIP_MS) p.remove("pf"); else p.putInt("pf", ms);
p.end();
pressToFlipMs = ms;
}
// ---- 時刻(マイクロ秒、エポック) ----
int64_t nowUs() {
struct timeval tv;
gettimeofday(&tv, nullptr);
return (int64_t)tv.tv_sec * 1000000LL + tv.tv_usec;
}
bool timeIsValid() {
return time(nullptr) > 1700000000;
} // 2023-11 以降
void waitUntilUs(int64_t t) {
for (;;) {
int64_t r = t - nowUs();
if (r <= 0) return;
if (r > 2000000) { // 残り 2 秒より長いときは、コマンドの受け付けを挟みながら、50 ms ずつ待つ
if (idleHook) idleHook();
int64_t w = (r - 1500000) / 1000;
delay(w > 50 ? 50 : w);
} else if (r > 30000) delay((r - 20000) / 1000); // 粗く待つ(Wi-Fi など他のタスクを動かす)
else if (r > 300) delayMicroseconds(r - 200);
}
}
// ---- 自前の SNTP(往復遅延が最小の測定を採用し、システム時刻を合わせる) ----
static const uint64_t NTP_UNIX_OFFSET = 2208988800ULL;
static void putNtpTs(uint8_t* p, int64_t us) {
uint32_t sec = (uint32_t)(us / 1000000LL + NTP_UNIX_OFFSET);
uint32_t frac = (uint32_t)(((uint64_t)(us % 1000000LL) << 32) / 1000000ULL);
for (int i = 0; i < 4; i++) {
p[i] = sec >> (24 - 8 * i);
p[4 + i] = frac >> (24 - 8 * i);
}
}
static int64_t getNtpTsUs(const uint8_t* p) {
uint32_t sec = 0, frac = 0;
for (int i = 0; i < 4; i++) {
sec = (sec << 8) | p[i];
frac = (frac << 8) | p[4 + i];
}
return ((int64_t)sec - (int64_t)NTP_UNIX_OFFSET) * 1000000LL + (int64_t)(((uint64_t)frac * 1000000ULL) >> 32);
}
// 1 回の問い合わせ。成功したら offUs(サーバ − 自分)と rttUs を返す
// err: 1=ソケット作成失敗 2=送信失敗 3=無応答 4=応答の不一致 5=サーバ応答として不正
bool ntpQuery(const IPAddress& ip, int64_t& offUs, int64_t& rttUs, int& err) {
err = 0;
WiFiUDP udp;
if (!udp.begin(12300)) {
err = 1;
return false;
}
uint8_t pkt[48] = { 0 };
pkt[0] = 0x1B; // LI=0, VN=3, Mode=3(client)
int64_t t1 = nowUs();
putNtpTs(pkt + 40, t1);
udp.beginPacket(ip, 123);
udp.write(pkt, 48);
if (!udp.endPacket()) {
err = 2;
return false;
}
uint32_t t0 = millis();
while (millis() - t0 < 1000) {
if (udp.parsePacket() >= 48) {
uint8_t r[48];
udp.read(r, 48);
int64_t t4 = nowUs();
if (memcmp(r + 24, pkt + 40, 8) != 0) {
err = 4;
continue;
} // 自分の要求への返答か
if ((r[0] & 7) != 4 || r[1] == 0) {
err = 5;
return false;
} // サーバ応答でない / Kiss-o'-Death
int64_t t2 = getNtpTsUs(r + 32), t3 = getNtpTsUs(r + 40);
offUs = ((t2 - t1) + (t3 - t4)) / 2;
rttUs = (t4 - t1) - (t3 - t2);
return true;
}
delay(1);
}
if (!err) err = 3;
return false;
}
int64_t lastNtpOffUs = 0, lastNtpRttUs = 0;
int lastNtpGood = 0;
// 最大 timeoutMs の間に、サーバへ複数回問い合わせ、往復遅延が最小の測定でシステム時刻を合わせる
bool ntpSyncInner(uint32_t timeoutMs, int want);
bool ntpSync(uint32_t timeoutMs, int want = 4) {
if (WiFi.status() != WL_CONNECTED) {
logln("[ntp] Wi-Fi が未接続です");
return false;
}
{
IPAddress d1, d2;
d1.fromString(DNS_PRIMARY);
d2.fromString(DNS_SECONDARY);
WiFi.setDNS(d1, d2); // DHCP の DNS に頼らない(0.0.0.0 を返す環境への対策)
}
WiFi.setSleep(false); // NTP の間だけ省電力を切る(往復遅延のぶれを減らす)
delay(300);
bool ok = ntpSyncInner(timeoutMs, want);
WiFi.setSleep(true); // すぐに省電力へ戻す
return ok;
}
bool ntpSyncInner(uint32_t timeoutMs, int want) {
const char* hosts[] = { NTP_SERVER1, NTP_SERVER2 };
IPAddress ips[2];
bool have[2] = { false, false };
uint32_t t0 = millis();
int good = 0, tries = 0, dnsFail = 0, fallbackIdx = 0, errCnt[6] = { 0 };
int64_t bestOff = 0, bestRtt = INT64_MAX;
while (millis() - t0 < timeoutMs && good < want && tries < 40) {
int h = tries % 2;
tries++;
IPAddress target;
bool viaFallback = false;
if (have[h]) {
target = ips[h];
} else {
IPAddress r;
if (WiFi.hostByName(hosts[h], r) && (uint32_t)r != 0) { // 0.0.0.0 は失敗として扱う
ips[h] = r;
have[h] = true;
target = r;
} else {
dnsFail++;
if (dnsFail <= 2) logf("[ntp] 名前解決に失敗(または 0.0.0.0): %s\r\n", hosts[h]);
if (dnsFail < 2) {
delay(300);
continue;
}
target.fromString(NTP_FALLBACK_IPS[fallbackIdx++ % NTP_FALLBACK_COUNT]); // 固定 IP の予備
viaFallback = true;
}
}
int64_t off, rtt;
int err;
if (tries <= 3) logf("[ntp] 問い合わせ %s -> %s%s\r\n", hosts[h], target.toString().c_str(), viaFallback ? " (固定IPの予備)" : "");
if (ntpQuery(target, off, rtt, err)) {
good++;
if (rtt < bestRtt) {
bestRtt = rtt;
bestOff = off;
}
} else if (err >= 1 && err <= 5) {
errCnt[err]++;
}
delay(150);
}
lastNtpGood = good;
if (good < 1 || bestRtt > 400000) {
logf("[ntp] 失敗: 有効 %d 回 / 試行 %d 回 (名前解決失敗 %d / ソケット %d / 送信失敗 %d / 無応答 %d / 不一致 %d / 不正 %d)\r\n",
good, tries, dnsFail, errCnt[1], errCnt[2], errCnt[3], errCnt[4], errCnt[5]);
return false;
}
lastNtpOffUs = bestOff;
lastNtpRttUs = bestRtt;
int64_t n = nowUs() + bestOff;
struct timeval tv = { (time_t)(n / 1000000LL), (suseconds_t)(n % 1000000LL) };
settimeofday(&tv, nullptr);
logf("[ntp] 同期: ずれ %+.1f ms, 往復遅延 %.1f ms (有効 %d/%d 回)\r\n", bestOff / 1000.0, bestRtt / 1000.0, good, tries);
return true;
}
// ---- ボタン(HIGH で押す) ----
void pressAtUs(int pin, int64_t t, int holdMs) {
waitUntilUs(t);
digitalWrite(pin, HIGH);
delay(holdMs);
digitalWrite(pin, LOW);
}
int pinOf(char c) {
switch (c) {
case 'M': return PIN_M;
case 'U': return PIN_U;
case 'D': return PIN_D;
case 'S': return PIN_S;
case 'P': return PIN_P;
}
return -1;
}
// ---- 基板の LED(GPIO8、active LOW): 起きている間は点灯、スリープ中は消灯。setup 中は点滅 ----
volatile bool ledBlink = false; // setup 中は 0.5 秒間隔で点滅
volatile bool ledOn = true; // 起きている間は点灯(スリープに入るときに false にする)
void ledTask(void*) {
for (;;) {
if (ledBlink) {
digitalWrite(PIN_LED, LOW); // 点灯
vTaskDelay(pdMS_TO_TICKS(LED_BLINK_MS));
digitalWrite(PIN_LED, HIGH); // 消灯
vTaskDelay(pdMS_TO_TICKS(LED_BLINK_MS));
} else {
digitalWrite(PIN_LED, ledOn ? LOW : HIGH);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
}
struct LedBlinkScope { // スコープの間だけ点滅する
LedBlinkScope() {
ledBlink = true;
}
~LedBlinkScope() {
ledBlink = false;
}
};
void initPins() {
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, HIGH); // 消灯
xTaskCreate(ledTask, "led", 2048, nullptr, 1, nullptr);
pinMode(PIN_P, OUTPUT);
digitalWrite(PIN_P, HIGH); // 起動直後に、まず電池を導通にしておく
const int bs[] = { PIN_M, PIN_U, PIN_D, PIN_S };
for (int p : bs) {
pinMode(p, OUTPUT);
digitalWrite(p, LOW);
}
}
// ---- 日次の同期手順(sync_daily.py と同じ) ----
// mStart: 分 m の頭(エポック秒)。m:05 に設定開始 → (m+2):00 ちょうどに次の分へ切り替わる。
// 戻り値: 実行できたら true
bool runDailySync(time_t mStart, uint8_t kind = synclog::KIND_MANUAL) {
using namespace synclog;
BusyScope busy; // 実行中は、読み取り専用のコマンドだけ、受け付ける
struct tm lt;
localtime_r(&mStart, <);
lastNtpOffUs = lastNtpRttUs = 0;
lastNtpGood = 0;
const uint32_t flipAt = (uint32_t)(mStart + 120);
if (lt.tm_min == 59) {
logln("[sync] 分 m=59 は不可(繰り上がり)。中止");
add(kind, RES_M59, 0, 0, 0, 0);
return false;
}
if (!timeIsValid()) {
logln("[sync] 時刻が未取得。中止");
add(kind, RES_NO_TIME, 0, 0, 0, 0);
return false;
}
if (!ntpSync(30000)) {
logln("[sync] NTP の取得に失敗。中止");
add(kind, RES_NTP_FAIL, 0, 0, lastNtpGood, 0);
return false;
}
const int64_t base = (int64_t)mStart * 1000000LL;
const int64_t B = base + 120LL * 1000000LL; // (m+2):00
const int64_t tPress = B - (int64_t)pressToFlipMs * 1000LL; // 最後の U
const int64_t tEnter = base + 5LL * 1000000LL;
if (nowUs() > tEnter - 1000000LL) {
logln("[sync] 開始時刻に間に合いません。中止");
add(kind, RES_LATE, lastNtpOffUs, lastNtpRttUs, lastNtpGood, 0);
return false;
}
logf("[sync] m=%02d:%02d 設定開始 m:05 / 最終 U = B-%.1fs\r\n", lt.tm_hour, lt.tm_min, pressToFlipMs / 1000.0);
pressAtUs(PIN_M, tEnter, HOLD_M_LONG_MS); // 設定モードへ
pressAtUs(PIN_M, base + 8400000LL, HOLD_M_MS); // 分の設定へ
const int ks[] = { 15, 27, 39, 51 };
for (int k : ks) { // キープアライブ(正味 0)
pressAtUs(PIN_U, base + (int64_t)k * 1000000LL, HOLD_SHORT_MS);
pressAtUs(PIN_D, base + (int64_t)k * 1000000LL + 450000LL, HOLD_SHORT_MS);
}
pressAtUs(PIN_U, tPress, HOLD_SHORT_MS); // 最終: 秒が 0 に戻る
add(kind, RES_OK, lastNtpOffUs, lastNtpRttUs, lastNtpGood, flipAt);
logf("[sync] 完了。次の分の切り替わり予定 %02d:%02d:00\r\n", (lt.tm_min + 2) / 60 ? (lt.tm_hour + 1) % 24 : lt.tm_hour, (lt.tm_min + 2) % 60);
delay(1000);
return true;
}
// ---- 電池入れ替え(リセット)後の時刻合わせ ----
// 時計を P で電池リセット(2017-01-01 12:00 AM)→ 時・分・年・月・日を設定 → 日次の同期手順で秒を 00 に合わせる。
// 事前にスライドスイッチ(P のバイパス)を OFF にしておくこと。
// 設計(机上): 分の最後の U を t_V = (m-1):10 に押し、フェーズBは m:25 から(t_V + 75 秒)。
constexpr int64_t SEC = 1000000LL;
constexpr int TAP_US = 250000; // 連打の間隔(押す 0.10 秒 + 離す 0.15 秒)
constexpr int YEAR_BASE = 2017; // リセット後の年
bool runFullSetup() {
LedBlinkScope blink; // setup 中(どの経路で終わっても、終了時に消灯)
BusyScope busy; // 実行中は、読み取り専用のコマンドだけ、受け付ける
if (!timeIsValid() || !ntpSync(30000)) {
logln("[setup] NTP の取得に失敗。中止");
synclog::add(synclog::KIND_SETUP_TIME, timeIsValid() ? synclog::RES_NTP_FAIL : synclog::RES_NO_TIME, 0, 0, lastNtpGood, 0);
return false;
}
time_t tvs;
struct tm lt;
int64_t tP0, tP1, tMlong, tHour0, tMmin, tA;
int h, V;
for (;;) {
time_t now = time(nullptr);
tvs = now + 60;
tvs = tvs - tvs % 60 + 10; // 次の :10 秒
for (;;) {
localtime_r(&tvs, <);
if (lt.tm_min >= 1 && lt.tm_min <= 56) break; // 分が 59→00 に絡む時間帯を避ける
tvs += 60;
}
h = lt.tm_hour;
V = lt.tm_min; // 設定する分(最後の U で到達)
const int64_t tV = (int64_t)tvs * SEC;
tA = tV - (int64_t)(V - 1) * TAP_US; // 分の最初の U
tMmin = tA - 1 * SEC; // M(分の設定へ)
int64_t lastHour = tMmin - 800000LL;
tHour0 = lastHour - (int64_t)(h > 0 ? (h - 1) : 0) * TAP_US;
tMlong = (h > 0 ? tHour0 : lastHour) - 3300000LL; // M 長押し(2.5 秒)の開始
tP1 = tMlong - (USE_24H ? 8 : 4) * SEC; // 24H 切り替え(U 長押し)の分、早める
// 時計の刻みの位相 φ は、電池を戻した時刻で決まる。切り替わりが、ちょうど秒の頭になるよう、
// P1 の小数部を P1_FRAC_US に合わせる(最大 1 秒、遅らせる。後続の押下は、絶対時刻なので、影響しない)
tP1 += (P1_FRAC_US - (tP1 % SEC) + SEC) % SEC;
tP0 = tP1 - 1500000LL;
if (tP0 > nowUs() + 1500000LL) break; // 間に合わなければ 1 分後ろへ
tvs += 60;
now = time(nullptr);
}
const int yTaps = lt.tm_year + 1900 - YEAR_BASE;
const int moTaps = lt.tm_mon; // 1月 → 現在の月
const int dTaps = lt.tm_mday - 1; // 1日 → 今日
if (yTaps < 0) {
logln("[setup] 年が不正。中止");
synclog::add(synclog::KIND_SETUP_TIME, synclog::RES_BAD_YEAR, 0, 0, 0, 0);
return false;
}
logf("[setup] 設定する時刻 %04d-%02d-%02d %02d:%02d (最後の分の U = :10 に押す)。P0 まで %.1f 秒\r\n",
lt.tm_year + 1900, lt.tm_mon + 1, lt.tm_mday, h, V, (tP0 - nowUs()) / 1e6);
{
double wait = (tP0 - nowUs()) / 1e6;
if (wait > 100) { // 通常は、約 46 秒。長いのは、分が 57〜00 の付近で、設定する分を、次の時間帯に送るため
time_t p0 = (time_t)(tP0 / 1000000LL);
struct tm pt;
localtime_r(&p0, &pt);
logf("[setup] 設定する分は 1〜56 に限るため(59 分 → 00 分の繰り上がりを避ける)、約 %.1f 分後(%02d:%02d:%02d)に、電池リセットを始めます。待機中も status などは使えます\r\n",
wait / 60.0, pt.tm_hour, pt.tm_min, pt.tm_sec);
}
}
// 1) 電池リセット
waitUntilUs(tP0);
digitalWrite(PIN_P, LOW);
waitUntilUs(tP1);
digitalWrite(PIN_P, HIGH);
logln("[setup] 電池リセット完了。設定開始");
if (USE_24H) {
pressAtUs(PIN_U, tP1 + 3 * SEC, HOLD_U_LONG_MS); // 通常表示で U 長押し = 24 時間表示へ
logln("[setup] 24 時間表示に切り替え");
}
// 2) 時
pressAtUs(PIN_M, tMlong, HOLD_M_LONG_MS);
for (int i = 0; i < h; i++) pressAtUs(PIN_U, tHour0 + (int64_t)i * TAP_US, HOLD_SHORT_MS);
// 3) 分
pressAtUs(PIN_M, tMmin, HOLD_M_MS);
for (int i = 0; i < V; i++) pressAtUs(PIN_U, tA + (int64_t)i * TAP_US, HOLD_SHORT_MS);
// 4) 年・月・日
int64_t t = (int64_t)tvs * SEC + 700000LL;
auto step = [&](int taps) {
pressAtUs(PIN_M, t, HOLD_M_MS);
t += 700000LL;
for (int i = 0; i < taps; i++) {
pressAtUs(PIN_U, t, HOLD_SHORT_MS);
t += TAP_US;
}
t += 450000LL;
};
step(yTaps);
step(moTaps);
step(dTaps);
pressAtUs(PIN_M, t, HOLD_M_MS); // 確定
synclog::add(synclog::KIND_SETUP_TIME, synclog::RES_OK, lastNtpOffUs, lastNtpRttUs, lastNtpGood, 0);
logln("[setup] フェーズA(時・分・年・月・日)完了");
// 5) フェーズB: 日次の手順で秒を 00 に合わせる(分 m = V+1 の頭から)
time_t mStart = tvs + 50; // (V+1):00
bool ok = runDailySync(mStart, synclog::KIND_SETUP_SEC);
logln(ok ? "[setup] 完了" : "[setup] フェーズB(00秒合わせ)が失敗");
return ok;
}
} // namespace clockctl
config.h
#pragma once
#include <Arduino.h>
#include "logts.h"
// ---- GPIO(HIGH で「押す」/ P は HIGH で電池が導通) ----
constexpr int PIN_M = 4;
constexpr int PIN_U = 3; // + ボタン
constexpr int PIN_D = 2; // - ボタン
constexpr int PIN_S = 1; // スヌーズ
constexpr int PIN_P = 0; // 電池(HIGH=導通 / LOW=切り離し)
constexpr int PIN_LED = 8; // 基板の LED(active LOW)。setup 中に 0.5 秒間隔で点滅
constexpr int LED_BLINK_MS = 500;
constexpr int PIN_BOOT = 9; // BOOT ボタン(基板でプルアップ)。通常動作中に長押しすると setup を実行
constexpr uint32_t BOOT_HOLD_MS = 2000; // 長押しと判定する時間
constexpr uint32_t BOOT_IGNORE_MS = 5000; // 起動直後は無視
// ---- 時刻 ----
constexpr const char* TZ_JST = "JST-9";
constexpr const char* NTP_SERVER1 = "ntp.nict.jp";
constexpr const char* NTP_SERVER2 = "pool.ntp.org";
// 名前解決が使えないとき(DNS が 0.0.0.0 を返すなど)の予備の NTP サーバ(NICT)
constexpr const char* NTP_FALLBACK_IPS[] = { "133.243.238.243", "133.243.238.164", "61.205.120.130" };
constexpr int NTP_FALLBACK_COUNT = sizeof(NTP_FALLBACK_IPS) / sizeof(NTP_FALLBACK_IPS[0]);
// DNS サーバを固定で上書きする(DHCP で配られる DNS が、正しい答えを返さない環境への対策)
constexpr const char* DNS_PRIMARY = "8.8.8.8";
constexpr const char* DNS_SECONDARY = "1.1.1.1";
// 1日4回の同期(時, 分)。分 m は 0〜58 にすること(59 は繰り上がりのため不可)
constexpr int SYNC_SLOTS[][2] = { { 0, 15 }, { 6, 15 }, { 12, 15 }, { 18, 15 } };
constexpr int SYNC_SLOT_COUNT = sizeof(SYNC_SLOTS) / sizeof(SYNC_SLOTS[0]);
constexpr int PRESYNC_LEAD_S = 45; // スロットの何秒前に NTP を取り直すか
// ---- 表示 ----
constexpr bool USE_24H = true; // setup 後の表示を 24 時間表示にする
constexpr int HOLD_U_LONG_MS = 2500; // 通常表示で U を長押し = 12/24 時間の切り替え
// ---- 実験で確かめた時計の挙動(sync_daily.py と同じ) ----
// 最後の U の押下 → 次の分への切り替わり [ms]。
// 時計は、秒を「1 秒ごとの刻み(位相 φ は、電池を戻した時刻で決まる)」にそろえて数える。切り替わりは、
// 「押した後の最初の刻み + 59 秒」。φ が 0 のとき、59500 は、刻みの窓(B-60〜B-59 秒)の真ん中で、±0.5 秒の余裕がある。
constexpr int PRESS_TO_FLIP_MS = 59500;
// 電池を戻す(P1)時刻の小数部 [us]。実測: φ = (P1 の小数部 + 約 0.22 秒) mod 1。φ を 0 にするため、0.78 秒に合わせる
constexpr int64_t P1_FRAC_US = 100000; // → φ ≈ +0.32 秒。毎日 setup する運用では、1 日の誤差が、−0.32〜+0.32 秒に収まる(0.78 なら φ≈0)
// ---- 1 日 1 回の自動 setup(電池リセットからの時刻合わせ。位相 φ を戻す)----
// 前提: P のスライドスイッチが、常に OFF(ON だと、時計が、リセットされず、時刻が、狂う)。
// シリアルの `autosetup on|off` で有効/無効を切り替える(NVS に保存。初期値は無効)。
constexpr int SETUP_SLOT_H = 3;
constexpr int SETUP_SLOT_M = 0;
constexpr int SETUP_TAIL_S = 6 * 60; // 窓の終了(setup は約 3.5 分 + 余裕)
constexpr int HOLD_SHORT_MS = 100; // U/D の押す時間
constexpr int HOLD_M_MS = 150; // M(短押し)
constexpr int HOLD_M_LONG_MS = 2500; // M 長押し(設定モードに入る)
// ---- 発熱対策 ----
constexpr int CPU_MHZ = 80; // 既定は 160。Wi-Fi は 80 MHz 以上で動く
constexpr int LOOP_DELAY_MS = 50; // メインループの間隔(BOOT ボタンの長押し検出は 2 秒なので十分)
// ---- 省電力(ライトスリープ) ----
constexpr bool AUTO_SLEEP = true;
constexpr uint32_t ACTIVE_GRACE_MS = 10UL * 60UL * 1000UL; // 起動後・シリアル入力後・BOOT 起床後は、この間、眠らない
constexpr int WAKE_LEAD_S = 8 * 60; // 同期スロットの何秒前に起きるか(RTC のずれを NTP で補正する余裕を含む)
constexpr int SYNC_TAIL_S = 3 * 60; // 同期スロットの何秒後まで起きているか(手順は約 2 分)
// ---- Wi-Fi コンソール(TCP。シリアルの代わりに、ケーブルなしで、コマンドを送る)----
// 接続: nc clocksync.local 2323 ("PIN: " と表示されたら PIN を入力)。PIN は、シリアルの `pin <数字>` で設定する(未設定の間は、無効)
constexpr uint16_t NET_CON_PORT = 2323;
constexpr const char* NET_CON_HOST = "clocksync"; // mDNS 名(clocksync.local)
constexpr uint32_t NET_CON_IDLE_MS = 10UL * 60UL * 1000UL; // 無操作で切断
// ---- Wi-Fi ポータル ----
constexpr const char* AP_PASSWORD = ""; // 空 = オープン(8 文字以上で WPA2)
constexpr uint32_t STA_TIMEOUT_MS = 40000; // 電波が弱い場所でも待てるように
logts.h
#pragma once
// タイムスタンプ付きのログ出力。行頭に JST の時刻(HH:MM:SS.mmm)を付ける。
// 時刻が未取得(1970 年台)のときは、起動からの経過秒(boot+SS.mmm)を付ける。
#include <Arduino.h>
#include <WiFi.h>
// 出力先: シリアル + net(TCPSerial。接続・認証済みのときだけ送られ、それ以外は捨てられる)
class DualOut : public Print {
public:
Print* net = nullptr;
size_t write(uint8_t c) override {
if (net) net->write(c); // ネットワークを先に(シリアルが詰まっても、遅れない)
Serial.write(c);
return 1;
}
size_t write(const uint8_t* b, size_t n) override {
if (net) { net->write(b, n); net->flush(); }
Serial.write(b, n);
return n;
}
};
inline DualOut out;
#include <stdarg.h>
#include <sys/time.h>
#include <time.h>
// 行頭の時刻(HH:MM:SS.mmm、または、boot+SS.mmm)を、buf に書く。書いた文字数を返す
inline int stampStr(char* buf, size_t n) {
struct timeval tv;
gettimeofday(&tv, nullptr);
if (tv.tv_sec > 1700000000) {
struct tm lt;
localtime_r(&tv.tv_sec, <);
return snprintf(buf, n, "%02d:%02d:%02d.%03ld ", lt.tm_hour, lt.tm_min, lt.tm_sec, (long)(tv.tv_usec / 1000));
}
uint32_t ms = millis();
return snprintf(buf, n, "boot+%lu.%03lu ", (unsigned long)(ms / 1000), (unsigned long)(ms % 1000));
}
inline void logStamp() {
char b[32];
int k = stampStr(b, sizeof(b));
out.write((const uint8_t*)b, k);
}
// ライブラリ(WiFiPortal)のログ用: 行頭に時刻を付けて、out に流す
class StampLog : public Print {
public:
size_t write(uint8_t c) override { return write(&c, 1); }
size_t write(const uint8_t* b, size_t n) override {
if (n == 0) return 0;
const bool stamp = atLineStart;
atLineStart = (b[n - 1] == '\n');
if (!stamp) { out.write(b, n); return n; }
char buf[300]; // 時刻と本文は、1 回で書く(別々だと、片方だけ捨てられて、行が欠ける)
int k = stampStr(buf, 32);
size_t m = min(n, sizeof(buf) - k);
memcpy(buf + k, b, m);
out.write((const uint8_t*)buf, k + m);
if (m < n) out.write(b + m, n - m);
return n;
}
private:
bool atLineStart = true;
};
inline StampLog libLog;
// 時刻 + 本文を、1 つのバッファにまとめて、1 回で書く(書き込みの回数を減らす)
inline void logf(const char* fmt, ...) {
char line[380];
int k = stampStr(line, sizeof(line));
va_list ap;
va_start(ap, fmt);
vsnprintf(line + k, sizeof(line) - k, fmt, ap);
va_end(ap);
out.write((const uint8_t*)line, strlen(line));
}
inline void logln(const String& s) {
char b[32];
int k = stampStr(b, sizeof(b));
String line = String(b).substring(0, k) + s + "\r\n";
out.write((const uint8_t*)line.c_str(), line.length());
}
synclog.h
#pragma once
// 同期の記録(直近 N 件を、NVS に保存。再起動しても残る)
#include <Arduino.h>
#include "logts.h"
#include <Preferences.h>
#include <time.h>
namespace synclog {
enum Kind : uint8_t { KIND_AUTO = 1,
KIND_MANUAL = 2,
KIND_SETUP_SEC = 3,
KIND_SETUP_TIME = 4 };
enum Result : uint8_t { RES_OK = 0,
RES_NTP_FAIL = 1,
RES_NO_TIME = 2,
RES_M59 = 3,
RES_LATE = 4,
RES_BAD_YEAR = 5 };
struct Rec {
uint32_t t; // 記録した時刻(エポック秒)
uint32_t flipAt; // 次の分の切り替わり予定(エポック秒、秒合わせのときのみ)
int16_t offX10; // NTP のずれ [0.1 ms]
uint16_t rttX10; // 往復遅延 [0.1 ms]
uint8_t kind;
uint8_t res;
uint8_t good; // NTP の有効な応答数
uint8_t rsv;
};
constexpr int N = 24;
Preferences prefs;
void add(uint8_t kind, uint8_t res, int64_t offUs, int64_t rttUs, int good, uint32_t flipAt) {
Rec r = {};
r.t = (uint32_t)time(nullptr);
r.flipAt = flipAt;
int64_t o = offUs / 100;
r.offX10 = (int16_t)constrain(o, -32000, 32000);
int64_t q = rttUs / 100;
r.rttX10 = (uint16_t)constrain(q, 0, 65000);
r.kind = kind;
r.res = res;
r.good = (uint8_t)good;
prefs.begin("synclog", false);
uint32_t n = prefs.getUInt("n", 0);
char key[8];
snprintf(key, sizeof(key), "r%u", (unsigned)(n % N));
prefs.putBytes(key, &r, sizeof(r));
prefs.putUInt("n", n + 1);
prefs.end();
}
const char* kindText(uint8_t k) {
switch (k) {
case KIND_AUTO: return "自動";
case KIND_MANUAL: return "手動sync";
case KIND_SETUP_SEC: return "setup(秒)";
case KIND_SETUP_TIME: return "setup(時刻)";
}
return "?";
}
const char* resText(uint8_t r) {
switch (r) {
case RES_OK: return "OK";
case RES_NTP_FAIL: return "NTP失敗";
case RES_NO_TIME: return "時刻未取得";
case RES_M59: return "m=59で中止";
case RES_LATE: return "間に合わず";
case RES_BAD_YEAR: return "年が不正";
}
return "?";
}
static void printRec(uint32_t idx, const Rec& r) {
struct tm lt;
time_t t = r.t;
localtime_r(&t, <);
char line[260]; // 1 行を 1 つのバッファにまとめて、1 回で書く(小さな書き込みを、何度も、送らない)
int k = snprintf(line, sizeof(line), "#%u %04d-%02d-%02d %02d:%02d:%02d %-10s %-10s", (unsigned)idx, lt.tm_year + 1900,
lt.tm_mon + 1, lt.tm_mday, lt.tm_hour, lt.tm_min, lt.tm_sec, kindText(r.kind), resText(r.res));
if (r.good) k += snprintf(line + k, sizeof(line) - k, " NTPずれ %+.1fms 往復 %.1fms (有効%d回)", r.offX10 / 10.0, r.rttX10 / 10.0, r.good);
if (r.flipAt) {
struct tm f;
time_t ft = r.flipAt;
localtime_r(&ft, &f);
k += snprintf(line + k, sizeof(line) - k, " 切替予定 %02d:%02d:%02d", f.tm_hour, f.tm_min, f.tm_sec);
}
if (k >= (int)sizeof(line) - 3) k = sizeof(line) - 3;
line[k++] = '\r';
line[k++] = '\n';
out.write((const uint8_t*)line, k);
}
// 新しい順に count 件を表示
void print(int count = N) {
prefs.begin("synclog", true);
uint32_t n = prefs.getUInt("n", 0);
if (n == 0) out.println("[log] 記録なし");
int shown = 0;
for (uint32_t i = n; i > 0 && shown < count && shown < N; i--, shown++) {
char key[8];
snprintf(key, sizeof(key), "r%u", (unsigned)((i - 1) % N));
Rec r;
if (prefs.getBytes(key, &r, sizeof(r)) == sizeof(r)) printRec(i, r);
}
prefs.end();
}
void clear() {
prefs.begin("synclog", false);
prefs.clear();
prefs.end();
}
} // namespace synclog
シリアル入出力例
22:06:47.562 [power] 起床。Wi-Fi に接続します
22:06:50.581 [wifi] 接続を試みます SSID="xxxxxxxxx" (パスワード xx 文字)
22:06:52.583 [wifi] 接続しました SSID="xxxxxxxxx"
22:06:52.584 [wifi] 接続: 192.168.0.xx
22:06:52.885 [ntp] 問い合わせ ntp.nict.jp -> 133.243.238.163
22:06:53.087 [ntp] 問い合わせ pool.ntp.org -> 162.159.200.123
22:06:53.251 [ntp] 問い合わせ ntp.nict.jp -> 133.243.238.163
22:06:55.009 [ntp] 同期: ずれ +1427.3 ms, 往復遅延 11.0 ms (有効 4/4 回)
ntp
22:07:25.580 [ntp] 問い合わせ ntp.nict.jp -> 133.243.238.163
22:07:25.738 [ntp] 問い合わせ pool.ntp.org -> 162.159.200.123
22:07:25.900 [ntp] 問い合わせ ntp.nict.jp -> 133.243.238.163
22:07:27.394 [ntp] 同期: ずれ -2.3 ms, 往復遅延 6.8 ms (有効 4/5 回)
sync
22:08:18.473 [ntp] 問い合わせ ntp.nict.jp -> 133.243.238.163
22:08:18.633 [ntp] 問い合わせ pool.ntp.org -> 162.159.200.123
22:08:18.794 [ntp] 問い合わせ ntp.nict.jp -> 133.243.238.163
22:08:19.111 [ntp] 同期: ずれ +0.2 ms, 往復遅延 7.4 ms (有効 4/4 回)
22:08:19.112 [sync] m=22:09 設定開始 m:05 / 最終 U = B-60.1s
22:10:00.603 [sync] 完了。次の分の切り替わり予定 22:11:00
status
22:11:19.531 [status] 保存済み SSID="xxxxxxxxx" portal=0
22:11:19.532 [status] 2026-10-14 22:11:19.531 valid=1 wifi=connected ip=192.168.0.xx rssi=-32
#4 2026-10-14 22:10:00 手動sync OK NTPずれ +0.1ms 往復 7.4ms (有効4回) 切替予定 22:11:00
22:11:19.535 [status] 次の同期スロット 00:15
22:11:19.535 [status] sleep=on あと 418 秒でスリープ。次の起床 00:07
log
#4 2026-10-14 22:10:00 手動sync OK NTPずれ +0.1ms 往復 7.4ms (有効4回) 切替予定 22:11:00
#3 2026-10-14 18:16:00 自動 OK NTPずれ -3.2ms 往復 6.0ms (有効4回) 切替予定 18:17:00
#2 2026-10-14 17:24:00 setup(秒) OK NTPずれ +0.7ms 往復 6.4ms (有効4回) 切替予定 17:25:00
#1 2026-10-14 17:22:22 setup(時刻) OK NTPずれ -3200.0ms 往復 11.8ms (有効4回)
まとめ
今回使用したダイソーのデジタル時計は、秒のリセット機能が無いタイプだったので、秒まで時刻を合わせる手順が煩雑になってしまいました。
AIに助けてもらわないと、手順の確立までに何日も要したと想像できます。
AIにどこまで操作を任せるかという範囲は、勝手に操作されてしまう危険と裏腹ですが、「更新前に必ず確認を求めること」と言っておけば、少しは歯止めになるかもしれません。
(PCやネットワークを完全に分離できるなら、もっと安心できますが)
応用
「カーボン接点」に「ラバーボタン(導電ゴム)」で押し下げるタイプのスイッチは、家電のリモコンや、ゲームコントローラーのボタンなど、多くの製品に使用されている構造です。
銅箔剥き出しのままだと、空気中の酸素や水分で酸化して錆びが発生し、接触不良を起こしてしまうため、酸化や摩耗に非常に強いカーボンインクを上に印刷することで、「何度も繰り返し押されるボタン」の耐久性を劇的に高めているそうです。(ただし、導通抵抗は500Ω程ある)
今回の『フォトMOSリレー』を使用したボタン操作は、マイコン側回路とボタン側回路は完全に絶縁されているため、どんな形態の、どんな配線のボタンにも応用できます。
余談ですが、『ゼロクロス フォトトライアック』を使用すれば、マイコンのGPIOで AC100VのON/OFF制御もできてしまいます。
以上
