1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ダイソーのデジタル時計をNTP同期時計に改造する(後編)

1
Posted at

BRW106FD9D41A26_000186.png

前編ではAIに頼って、この時計の秒が00リセットするタイミングを調べ、秒合わせ手順と、初期設定時の 年月日・時分を合わせる手順を確立してもらいました。

後編では、基板作成と NTP同期させるプログラミングです。

回路図

最終的な回路図を以下に示します。
ESP32-C3 を使いました。

  • 抵抗内蔵LEDを使用しているので、LEDの電流制限抵抗は回路図に出てきません
  • (右端の)電池遮断スイッチ回路のLEDは、『遮断時に点灯、通常の通電時は消灯』とするため、LEDの向きが他のスイッチと逆向きになっています。
    また、バイパス用スライドスイッチを付けて、(マイコンでの制御無関係に)常時通電できるようにしています
  • フォトMOSリレーは、手持ちのTLP241Bを使用したが、『極性なし(AC/DC 両用のタイプ)、小信号用、オン抵抗が数Ω〜数十Ω程度のもの』であれば何でも良いです
d-clock-circuit.png

前編で登場したハードと この回路図は、タクトスイッチの取り付け位置が違います。
前編では、フォトMOSリレーのLED側に付けていましたが、この回路では、フォトMOSリレーのリレー側に付けています。(マイコン未使用時も)時計本体のボタンとして機能させるためです。

ユニバーサル基板に実装

配線イメージを以下に示します。

ユニバーサル0.png

実際に出来上がったハードの写真を示します。
(時計への取り付けは『仮』状態)

IMG_9342.jpeg

ソフトウェア

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 画面が開く。
IMG_9345.PNG

接続先のSSIDを一覧から選択し、パスワードを入力する。

IMG_9343.jpeg IMG_9344.PNG IMG_9346.PNG

『保存して再起動』ボタンをタップすると、ESP32のフラッシュに設定値を保存して再起動する。→ 設定したWiFiルータに接続する。
IMG_9347.jpeg

シリアルコマンド

シリアルモニターで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

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, &lt);
  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, &lt);
  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, &lt);
  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, &lt);
  // 窓の候補: 同期スロット(+ 自動 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, &lt);
  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);
  lt.tm_hour = SETUP_SLOT_H;
  lt.tm_min = SETUP_SLOT_M;
  lt.tm_sec = 0;
  time_t ts = mktime(&lt);
  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, &lt);
    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

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, &lt);
  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, &lt);
      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

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

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, &lt);
    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

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, &lt);
  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やネットワークを完全に分離できるなら、もっと安心できますが)

応用

SUDZ.jpeg

「カーボン接点」に「ラバーボタン(導電ゴム)」で押し下げるタイプのスイッチは、家電のリモコンや、ゲームコントローラーのボタンなど、多くの製品に使用されている構造です。

銅箔剥き出しのままだと、空気中の酸素や水分で酸化して錆びが発生し、接触不良を起こしてしまうため、酸化や摩耗に非常に強いカーボンインクを上に印刷することで、「何度も繰り返し押されるボタン」の耐久性を劇的に高めているそうです。(ただし、導通抵抗は500Ω程ある)

今回の『フォトMOSリレー』を使用したボタン操作は、マイコン側回路とボタン側回路は完全に絶縁されているため、どんな形態の、どんな配線のボタンにも応用できます。


余談ですが、『ゼロクロス フォトトライアック』を使用すれば、マイコンのGPIOで AC100VのON/OFF制御もできてしまいます。



以上

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?