Skip to content

Instantly share code, notes, and snippets.

@gpsnmeajp
Created April 4, 2026 23:18
Show Gist options
  • Select an option

  • Save gpsnmeajp/d2e185dc1600ea611dc50e053cadb1e4 to your computer and use it in GitHub Desktop.

Select an option

Save gpsnmeajp/d2e185dc1600ea611dc50e053cadb1e4 to your computer and use it in GitHub Desktop.
M5DinCPAPAlert
// ============================================================
// 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