Created
April 4, 2026 23:18
-
-
Save gpsnmeajp/d2e185dc1600ea611dc50e053cadb1e4 to your computer and use it in GitHub Desktop.
M5DinCPAPAlert
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // M5 Din Meter CPAP マスク装着忘れ警報機 | |
| // - M5Unified / M5Stamp S3 (M5 Din Meter) | |
| // ============================================================ | |
| #include <WiFi.h> | |
| #include <Preferences.h> | |
| #include <M5Unified.h> | |
| #if __has_include(<esp_sntp.h>) | |
| #include <esp_sntp.h> | |
| #define SNTP_ENABLED 1 | |
| #elif __has_include(<sntp.h>) | |
| #include <sntp.h> | |
| #define SNTP_ENABLED 1 | |
| #endif | |
| #ifndef SNTP_ENABLED | |
| #define SNTP_ENABLED 0 | |
| #endif | |
| // ============================================================ | |
| // 設定定数 | |
| // ============================================================ | |
| static const char* WIFI_SSID = "$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"; | |
| static const char* WIFI_PASSWORD = "$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"; | |
| static const char* NTP_TIMEZONE = "JST-9"; | |
| static const char* NTP_SERVER1 = "ntp.nict.jp"; | |
| static const char* NTP_SERVER2 = "0.pool.ntp.org"; | |
| static const char* NTP_SERVER3 = "1.pool.ntp.org"; | |
| static constexpr uint8_t ENCODER_A_PIN = 40; | |
| static constexpr uint8_t ENCODER_B_PIN = 41; | |
| static constexpr uint8_t MASK_SWITCH_PIN = G1; // PORT B IN (G1) | |
| static constexpr uint32_t SCREEN_TIMEOUT_MS = 30000; // 30秒 | |
| static constexpr uint32_t ALERT_DURATION_MS = 600000; // 10分 | |
| static constexpr uint32_t LONG_PRESS_MS = 5000; // 5秒 | |
| static constexpr uint8_t VOLUME_MIN = 1; | |
| static constexpr uint8_t VOLUME_MAX = 255; | |
| static constexpr uint8_t VOLUME_DEFAULT = 128; | |
| static constexpr uint8_t DISPLAY_BRIGHTNESS = 200; | |
| static constexpr int VOLUME_STEP = 5; | |
| // ============================================================ | |
| // ロータリーエンコーダ ISR (2相デコード + チャタリング除去) | |
| // ============================================================ | |
| static portMUX_TYPE s_encMux = portMUX_INITIALIZER_UNLOCKED; | |
| static volatile int32_t s_encDelta = 0; | |
| static volatile uint8_t s_encPrev = 0; | |
| static volatile int8_t s_encAccum = 0; | |
| // 状態遷移テーブル: index = (前回AB << 2) | 今回AB | |
| // 0 = 無効(バウンス), +1 = CW, -1 = CCW | |
| static const DRAM_ATTR int8_t s_encTable[16] = { | |
| 0, -1, 1, 0, | |
| 1, 0, 0, -1, | |
| -1, 0, 0, 1, | |
| 0, 1, -1, 0 | |
| }; | |
| static void IRAM_ATTR onEncoderChange() { | |
| uint8_t cur = (digitalRead(ENCODER_A_PIN) << 1) | |
| | digitalRead(ENCODER_B_PIN); | |
| int8_t dir = s_encTable[(s_encPrev << 2) | cur]; | |
| s_encPrev = cur; | |
| if (dir == 0) return; // 無効遷移 (バウンス) | |
| s_encAccum += dir; | |
| if (s_encAccum >= 4) { // 1 detent = 4 遷移 | |
| s_encAccum = 0; | |
| portENTER_CRITICAL_ISR(&s_encMux); | |
| ++s_encDelta; | |
| portEXIT_CRITICAL_ISR(&s_encMux); | |
| } else if (s_encAccum <= -4) { | |
| s_encAccum = 0; | |
| portENTER_CRITICAL_ISR(&s_encMux); | |
| --s_encDelta; | |
| portEXIT_CRITICAL_ISR(&s_encMux); | |
| } | |
| } | |
| static int32_t readEncoderDelta() { | |
| portENTER_CRITICAL(&s_encMux); | |
| int32_t d = s_encDelta; | |
| s_encDelta = 0; | |
| portEXIT_CRITICAL(&s_encMux); | |
| return d; | |
| } | |
| // ============================================================ | |
| // グローバル状態 | |
| // ============================================================ | |
| static Preferences s_prefs; | |
| static M5Canvas s_canvas(&M5.Display); | |
| // 音量 | |
| static uint8_t s_volume = VOLUME_DEFAULT; | |
| static bool s_volDirty = false; | |
| // マスクスイッチ (LOW = 装着 / HIGH = 未装着) | |
| static bool s_maskOn = false; | |
| // 画面 | |
| static bool s_screenOn = true; | |
| static uint32_t s_lastActMs = 0; | |
| // 警報 | |
| static bool s_alertActive = false; | |
| static bool s_alertSilenced = false; | |
| static uint32_t s_alertStartMs = 0; | |
| static int s_lastAlertHour = -1; // 同一時間帯の再発防止 | |
| static const char* s_alertMsg = ""; | |
| static bool s_alertCorrectMask = false; // この状態になったら警報停止 | |
| // 点滅・ビープ | |
| static bool s_blinkOn = true; | |
| static uint32_t s_lastBlinkMs = 0; | |
| static uint32_t s_lastBeepMs = 0; | |
| static bool s_beepHigh = false; | |
| // ボタン長押し追跡 | |
| static uint32_t s_btnDownMs = 0; | |
| static bool s_btnHandled = false; | |
| // NTP 同期済みフラグ / 最終同期日 | |
| static bool s_timeSynced = false; | |
| static int s_lastNtpDay = -1; // 日次NTP同期済み日 (tm_mday) | |
| // ============================================================ | |
| // WiFi + NTP 同期 | |
| // ============================================================ | |
| static void syncNtp() { | |
| configTzTime(NTP_TIMEZONE, NTP_SERVER1, NTP_SERVER2, NTP_SERVER3); | |
| WiFi.begin(WIFI_SSID, WIFI_PASSWORD); | |
| for (int i = 0; i < 20 && WiFi.status() != WL_CONNECTED; ++i) { | |
| delay(500); | |
| } | |
| if (WiFi.status() == WL_CONNECTED) { | |
| #if SNTP_ENABLED | |
| for (int i = 0; i < 30; ++i) { | |
| if (sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) { | |
| s_timeSynced = true; | |
| break; | |
| } | |
| delay(1000); | |
| } | |
| #else | |
| delay(2000); | |
| struct tm ti; | |
| s_timeSynced = getLocalTime(&ti, 10000); | |
| #endif | |
| if (s_timeSynced) { | |
| time_t t = time(nullptr) + 1; | |
| while (t > time(nullptr)) { delay(10); } | |
| M5.Rtc.setDateTime(gmtime(&t)); | |
| } | |
| } | |
| WiFi.disconnect(true); | |
| WiFi.mode(WIFI_OFF); | |
| } | |
| // ============================================================ | |
| // ヘルパー関数 | |
| // ============================================================ | |
| static void wakeScreen() { | |
| if (!s_screenOn) { | |
| M5.Display.setBrightness(DISPLAY_BRIGHTNESS); | |
| s_screenOn = true; | |
| } | |
| s_lastActMs = millis(); | |
| } | |
| static void sleepScreen() { | |
| M5.Display.setBrightness(0); | |
| s_screenOn = false; | |
| if (s_volDirty) { | |
| s_prefs.begin("cpap", false); | |
| s_prefs.putUChar("vol", s_volume); | |
| s_prefs.end(); | |
| s_volDirty = false; | |
| } | |
| } | |
| static void startAlert(const char* msg, int srcHour) { | |
| s_alertActive = true; | |
| s_alertSilenced = false; | |
| s_alertStartMs = millis(); | |
| s_alertMsg = msg; | |
| s_lastAlertHour = srcHour; | |
| s_alertCorrectMask = !s_maskOn; // 現在(異常)の反転が正常 | |
| s_blinkOn = true; | |
| s_lastBlinkMs = millis(); | |
| s_lastBeepMs = 0; | |
| s_beepHigh = false; | |
| wakeScreen(); | |
| } | |
| static void silenceAlert() { | |
| s_alertSilenced = true; | |
| M5.Speaker.stop(); | |
| if (s_screenOn) { | |
| M5.Display.setBrightness(DISPLAY_BRIGHTNESS); | |
| } | |
| } | |
| static void endAlert() { | |
| s_alertActive = false; | |
| s_alertSilenced = false; | |
| s_alertMsg = ""; | |
| M5.Speaker.stop(); | |
| M5.Display.setBrightness(DISPLAY_BRIGHTNESS); | |
| wakeScreen(); | |
| } | |
| // ============================================================ | |
| // setup | |
| // ============================================================ | |
| void setup() { | |
| auto cfg = M5.config(); | |
| cfg.serial_baudrate = 115200; | |
| cfg.internal_spk = true; | |
| M5.begin(cfg); | |
| // --- NVS から音量読込 --- | |
| s_prefs.begin("cpap", true); | |
| s_volume = s_prefs.getUChar("vol", VOLUME_DEFAULT); | |
| s_prefs.end(); | |
| if (s_volume < VOLUME_MIN) s_volume = VOLUME_MIN; | |
| M5.Speaker.setVolume(s_volume); | |
| // --- ディスプレイ初期化 --- | |
| if (M5.Display.width() < M5.Display.height()) { | |
| M5.Display.setRotation(M5.Display.getRotation() ^ 1); | |
| } | |
| M5.Display.setBrightness(DISPLAY_BRIGHTNESS); | |
| M5.Display.fillScreen(TFT_BLACK); | |
| int dw = M5.Display.width(); | |
| int dh = M5.Display.height(); | |
| s_canvas.createSprite(dw, dh); | |
| s_canvas.setTextWrap(false); | |
| // --- GPIO 初期化 --- | |
| pinMode(MASK_SWITCH_PIN, INPUT_PULLUP); | |
| gpio_pullup_en((gpio_num_t)MASK_SWITCH_PIN); | |
| gpio_pulldown_dis((gpio_num_t)MASK_SWITCH_PIN); | |
| pinMode(ENCODER_A_PIN, INPUT_PULLUP); | |
| pinMode(ENCODER_B_PIN, INPUT_PULLUP); | |
| s_encPrev = (digitalRead(ENCODER_A_PIN) << 1) | digitalRead(ENCODER_B_PIN); | |
| attachInterrupt(digitalPinToInterrupt(ENCODER_A_PIN), onEncoderChange, CHANGE); | |
| attachInterrupt(digitalPinToInterrupt(ENCODER_B_PIN), onEncoderChange, CHANGE); | |
| // --- WiFi + NTP 同期 --- | |
| s_canvas.fillSprite(TFT_BLACK); | |
| s_canvas.setFont(&fonts::lgfxJapanGothic_16); | |
| s_canvas.setTextDatum(MC_DATUM); | |
| s_canvas.setTextColor(TFT_WHITE); | |
| s_canvas.drawString("NTP同期中...", dw / 2, dh / 2); | |
| s_canvas.pushSprite(0, 0); | |
| syncNtp(); | |
| // 起動日を記録 (当日の日次同期をスキップ) | |
| { | |
| time_t now_t = time(nullptr); | |
| struct tm* ini = localtime(&now_t); | |
| s_lastNtpDay = ini->tm_mday; | |
| } | |
| s_lastActMs = millis(); | |
| } | |
| // ============================================================ | |
| // loop | |
| // ============================================================ | |
| void loop() { | |
| M5.update(); | |
| uint32_t now = millis(); | |
| // ---- ロータリーエンコーダ (回転) ---- | |
| int32_t enc = readEncoderDelta(); | |
| if (enc != 0) { | |
| wakeScreen(); | |
| if (s_alertActive && !s_alertSilenced) { | |
| silenceAlert(); | |
| } else { | |
| int v = (int)s_volume + enc * VOLUME_STEP; | |
| v = constrain(v, VOLUME_MIN, VOLUME_MAX); | |
| s_volume = (uint8_t)v; | |
| M5.Speaker.setVolume(s_volume); | |
| s_volDirty = true; | |
| } | |
| } | |
| // ---- ロータリーエンコーダ (押下 / 長押し) ---- | |
| if (M5.BtnA.wasPressed()) { | |
| wakeScreen(); | |
| s_btnDownMs = now; | |
| s_btnHandled = false; | |
| if (s_alertActive && !s_alertSilenced) { | |
| silenceAlert(); | |
| s_btnHandled = true; | |
| } | |
| } | |
| if (M5.BtnA.isPressed() && !s_btnHandled) { | |
| if (now - s_btnDownMs >= LONG_PRESS_MS) { | |
| s_btnHandled = true; | |
| startAlert("テスト警報", -2); | |
| } | |
| } | |
| // ---- マスクスイッチ読取 ---- | |
| { | |
| bool maskNow = (digitalRead(MASK_SWITCH_PIN) == LOW); | |
| if (maskNow != s_maskOn) { | |
| s_maskOn = maskNow; | |
| wakeScreen(); | |
| } | |
| } | |
| // ---- 現在時刻取得 ---- | |
| time_t t = time(nullptr); | |
| struct tm* lt = localtime(&t); | |
| int hh = lt->tm_hour; | |
| int mm = lt->tm_min; | |
| int ss = lt->tm_sec; | |
| // ---- AM 2:00 日次 NTP 同期 ---- | |
| if (hh == 2 && lt->tm_mday != s_lastNtpDay) { | |
| s_lastNtpDay = lt->tm_mday; | |
| syncNtp(); | |
| } | |
| // ---- 警報条件チェック ---- | |
| bool inWindow = (hh == 1 && mm < 10) | |
| || (hh == 8 && mm < 10) | |
| || (hh == 21 && mm < 10); | |
| if (!s_alertActive) { | |
| // 時間帯外に入ったらフラグリセット | |
| if (!inWindow && s_lastAlertHour >= 0) { | |
| s_lastAlertHour = -1; | |
| } | |
| // AM 1:00-1:10 : フックにマスクがある (HIGH/未装着) → 顔に装着忘れ → 警報 | |
| if (hh == 1 && mm < 10 && !s_maskOn && s_lastAlertHour != 1) { | |
| startAlert("マスク未装着!", 1); | |
| } | |
| // AM 8:00-8:10 : フックにマスクがない (LOW/装着中) → 起床後フックに戻してない → 警報 | |
| else if (hh == 8 && mm < 10 && s_maskOn && s_lastAlertHour != 8) { | |
| startAlert("起床時間です!", 8); | |
| } | |
| // PM 9:00-9:10 : フックにマスクがない (LOW/装着中) → フックに未返却 → 警報 | |
| else if (hh == 21 && mm < 10 && s_maskOn && s_lastAlertHour != 21) { | |
| startAlert("マスク未返却!", 21); | |
| } | |
| } | |
| // ---- 警報処理 ---- | |
| if (s_alertActive) { | |
| // マスクが正しい状態になったら警報停止 (時間帯内は再発可能) | |
| if (s_maskOn == s_alertCorrectMask) { | |
| endAlert(); | |
| s_lastAlertHour = -1; // 再発を許可 | |
| } else if ((now - s_alertStartMs) >= ALERT_DURATION_MS) { | |
| endAlert(); | |
| } else if (!s_alertSilenced) { | |
| // 画面点滅 (500ms 間隔) | |
| if (now - s_lastBlinkMs >= 500) { | |
| s_blinkOn = !s_blinkOn; | |
| M5.Display.setBrightness(s_blinkOn ? DISPLAY_BRIGHTNESS : 0); | |
| s_lastBlinkMs = now; | |
| } | |
| // ビープ音 (800ms 間隔で交互2音) | |
| if (now - s_lastBeepMs >= 800) { | |
| s_beepHigh = !s_beepHigh; | |
| M5.Speaker.tone(s_beepHigh ? 2400.0f : 1800.0f, 500, 0, true); | |
| s_lastBeepMs = now; | |
| } | |
| } | |
| } | |
| // ---- 画面消灯タイマー ---- | |
| if (s_screenOn && !s_alertActive) { | |
| if (now - s_lastActMs >= SCREEN_TIMEOUT_MS) { | |
| sleepScreen(); | |
| } | |
| } | |
| // ---- 画面描画 ---- | |
| if (s_screenOn) { | |
| int cw = s_canvas.width(); | |
| s_canvas.fillSprite(TFT_BLACK); | |
| // 時刻 (大きめフォント) | |
| char buf[32]; | |
| snprintf(buf, sizeof(buf), "%02d:%02d:%02d", hh, mm, ss); | |
| s_canvas.setFont(&fonts::Font4); | |
| s_canvas.setTextDatum(TC_DATUM); | |
| s_canvas.setTextColor(TFT_WHITE); | |
| s_canvas.drawString(buf, cw / 2, 4); | |
| // 日付 | |
| snprintf(buf, sizeof(buf), "%04d/%02d/%02d", | |
| lt->tm_year + 1900, lt->tm_mon + 1, lt->tm_mday); | |
| s_canvas.setFont(&fonts::Font2); | |
| s_canvas.drawString(buf, cw / 2, 34); | |
| // マスクスイッチ状態 | |
| s_canvas.setFont(&fonts::lgfxJapanGothic_16); | |
| s_canvas.setTextColor(s_maskOn ? TFT_GREEN : TFT_YELLOW); | |
| s_canvas.drawString( | |
| s_maskOn ? "SW: ON (装着)" : "SW: OFF (未装着)", | |
| cw / 2, 56); | |
| // 音量 | |
| snprintf(buf, sizeof(buf), "音量: %d", s_volume); | |
| s_canvas.setTextColor(TFT_CYAN); | |
| s_canvas.drawString(buf, cw / 2, 80); | |
| // 警報メッセージ or NTP 状態 | |
| if (s_alertActive && s_alertMsg[0]) { | |
| s_canvas.setTextColor(s_alertSilenced ? TFT_YELLOW : TFT_RED); | |
| s_canvas.drawString(s_alertMsg, cw / 2, 104); | |
| } else if (!s_timeSynced) { | |
| s_canvas.setTextColor(TFT_ORANGE); | |
| s_canvas.drawString("NTP未同期", cw / 2, 104); | |
| } | |
| s_canvas.pushSprite(0, 0); | |
| } | |
| delay(20); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment