这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 活动中心 » 板卡试用 » 【M5PAPERESP32EINKDEVKIT评测】番茄钟-专注工作每一次

共4条 1/1 1 跳转至

【M5PAPERESP32EINKDEVKIT评测】番茄钟-专注工作每一次

助工
2026-03-26 21:43:29   被打赏 50 分(兑奖)     打赏

之前跟大家分享了禅意时钟,有朋友问我能不能做个番茄钟,就顺手写了一个。今天来跟大家聊聊这个番茄钟的实现细节。

番茄钟这个概念相信大家都知道:工作 25 分钟,休息 5 分钟,每四个番茄钟长休息一次。但真正写起来,还是有不少门道的。

界面设计

先说说界面设计。番茄钟的界面需要展示:

  • 顶部:当前状态(空闲/工作中/休息中/已暂停)

  • 中间:大号倒计时显示

  • 进度条:用圆点展示工作进度

  • 番茄计数:完成了多少个番茄

  • 底部:时间选择框(5/10/15/20/25分钟)

02_pomodoro_idle.png


布局坐标参考

  • 屏幕尺寸:540 x 960(竖屏)

  • 顶部状态文字:y=60

  • 倒计时显示:y=240

  • 进度圆点:y=390

  • 番茄计数:y=550

  • 分隔线:y=620

  • 时间选择框:y=750

  • 底部提示:y=880

操作逻辑

番茄钟的交互设计遵循"所见即所用"的原则,每个状态的界面显示和用户可执行的操作保持一致。

界面显示与操作对照表当前状态顶部文字底部提示可执行操作





空闲"番茄钟""点击时间框,开始计时"单指点击时间框开始计时
工作中"专注工作中""双指点击暂停"单指点击时间框无响应;双指暂停
已暂停"已暂停""双指点击继续"双指继续
休息中"休息一下"或"长休息""双指点击停止"双指停止,返回空闲

触摸交互设计

┌─────────────────────────────────────┐
│            顶部状态区 (y=60)        │
│         "专注工作中" / "已暂停"      │
├─────────────────────────────────────┤
│                                     │
│           大号倒计时 (y=240)         │
│              04:45                  │
│                                     │
│         ●●●●●●●●○○ (y=390)          │
│           进度圆点                   │
│                                     │
│      已完成 3 个番茄 (y=550)         │
│                                     │
├─────────────────────────────────────┤
│           分隔线 (y=620)            │
├─────────────────────────────────────┤
│  ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐    │
│  │ 5 │ │10 │ │15 │ │20 │ │25 │    │
│  │分钟│ │分钟│ │分钟│ │分钟│ │分钟│    │
│  └───┘ └───┘ └───┘ └───┘ └───┘    │
│         时间选择框 (y=750)           │
├─────────────────────────────────────┤
│     底部提示 (y=880)                 │
│    "双指点击暂停" / "双指点击继续"    │
└─────────────────────────────────────┘

状态转换操作流

                    ┌─────────────────┐
                    │                 │
        点击时间框   │                 │  双指点击
        ───────────────> IDLE ───────────────> WORK
                    │                 │
                    │                 │  计时结束
                    │                 │  ───────────────> BREAK
                    │                 │
                    │                 │  双指点击
                    │                 │  ───────────────> IDLE
                    │                 │
                    │    PAUSED <──────────────────
                    │                 │
                    │                 │  双指点击
                    │                 │  ───────────────> WORK
                    │                 │
                    │                 │  双指点击
                    │                 │  ───────────────> IDLE
                    └─────────────────┘

关键交互细节

  1. 单指点击时间框

    • 仅在 IDLE 状态下有效

    • 点击后直接进入计时状态,无确认步骤

    • 重新点击时间框可重新选择时长

  2. 双指点击

    • WORK 状态:暂停计时

    • PAUSED 状态:继续计时

    • BREAK/LONG_BREAK 状态:停止,返回 IDLE

  3. 时间框选择

    • 仅在 IDLE 状态下可选

    • 选中项高亮显示

    • 计时开始后不可更改

  4. 进度圆点

    • 仅在工作状态显示

    • 8 个圆点代表一个番茄钟周期

    • 填充数量 = (总时长 - 剩余时间) / 总时长 * 8

核心功能实现1. 状态机设计

番茄钟有 5 种状态,用状态机来管理再合适不过:


# 状态常量
STATE_WORK = 0        # 工作中
STATE_BREAK = 1       # 短休息
STATE_LONG_BREAK = 2  # 长休息
STATE_IDLE = 3        # 空闲
STATE_PAUSED = 4       # 已暂停

STATE_NAMES = ["工作中", "短休息", "长休息", "空闲", "已暂停"]


状态转换图:

     点击时间框
  ────────────────>
IDLE ───────────> WORK ───────────> BREAK ───────────> IDLE
      <──────────       <──────────       <──────────
                         长休息            计时结束
                          (4个番茄后)

2. 墨水屏刷新策略(重点!)

这是番茄钟最关键的部分。墨水屏不能频繁刷新,但番茄钟需要实时显示倒计时。

我设计了一套三级刷新策略

刷新场景刷新方式说明




启动/进入计时/进入休息/返回IDLE全屏刷新状态彻底改变时
每分钟全屏刷新整分钟时刻
每15秒(30秒/45秒)局部刷新只刷新时间和进度
暂停/继续最小局部刷新只刷新顶部状态+时间+底部提示


def loop():
    # 计时中的刷新逻辑
    if current_state == STATE_WORK or current_state == STATE_BREAK or current_state == STATE_LONG_BREAK:
        elapsed = time.time() - start_time
        display_time = get_display_time()
        # 刷新时刻:remaining = 285, 270, 255, 240, ... (即 remaining_time - n*15)
        # 刷新条件:display_time <= remaining_time - (refresh_count + 1) * 15
        next_refresh_threshold = remaining_time - (refresh_count + 1) * 15

        if display_time <= next_refresh_threshold and display_time > 0:
            if elapsed % 60 == 0 and elapsed > 0:
                # 整分钟时全屏刷新
                log("%d秒到,全屏刷新" % int(elapsed))
                draw_all()
            else:
                # 15秒、30秒、45秒时局部刷新
                log("%d秒到,局部刷新 remaining=%s" % (int(elapsed), format_time(display_time)))
                partial_refresh_time_progress()
            refresh_count += 1


3. 暂停/继续的精妙实现

这是最难的部分。用户要求:暂停后再继续,刷新节奏不能乱

举个例子:

  • 选择 5 分钟(05:00)

  • 工作到 04:41 时暂停(已过去 19 秒)

  • 继续后,下一次刷新应该在 04:30(再过 11 秒)

  • 而不是从 04:41 重新算 15 秒

实现方案:


# 全局变量
elapsed_at_pause = 0  # 暂停时的总elapsed

def pause_timer():
    global current_state, paused_remaining, start_time, remaining_time, elapsed_at_pause
    if current_state == STATE_WORK:
        paused_remaining = get_display_time()
        elapsed_at_pause = time.time() - start_time  # 保存暂停时的总elapsed
        current_state = STATE_PAUSED
        partial_refresh_minimal()
        start_time = 0

def resume_timer():
    global current_state, remaining_time, start_time, refresh_count, elapsed_at_pause
    if current_state == STATE_PAUSED:
        current_state = STATE_WORK
        # 关键:start_time 向后偏移,保持 elapsed 的连续性
        start_time = time.time() - elapsed_at_pause
        partial_refresh_minimal()


这样 get_display_time() 就能正确计算:remaining_time - (elapsed_at_pause + new_elapsed)

4. 触摸检测

M5Paper 支持多点触控,我的设计是:

触摸方式功能



单指点击时间框开始计时
单指点击其他区域空闲时不操作
双指点击暂停/继续/停止(根据当前状态)


def handle_touch(x, y, count):
    if count == 1:
        # 单指
        if current_state == STATE_IDLE:
            if is_in_time_box(x, y):
                check_time_option_touch(x, y)
                start_work()
    elif count == 2:
        # 双指
        if current_state == STATE_WORK:
            pause_timer()
        elif current_state == STATE_PAUSED:
            resume_timer()
        else:
            stop_timer()


注意:触摸释放时才执行动作,而不是按下时。这样可以避免快速点击产生的误判。

5. WiFi 与时间同步


def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        wlan.disconnect()
        wlan.connect(WIFI_SSID, WIFI_PASSWORD)
        timeout = 30
        while not wlan.isconnected() and timeout > 0:
            time.sleep(1)
            timeout -= 1
    return wlan.isconnected()

def sync_time():
    try:
        ntptime.settime()
        return True
    except:
        return False


完整代码


# 02_番茄钟 - Pomodoro Timer
# 功能:番茄工作法计时器,可选时长
# 设计:简洁专注,墨水屏友好

import M5
from M5 import *
import time
import ntptime
import network

# ============== WiFi配置 ==============
WIFI_SSID = "OpenBSD"
WIFI_PASSWORD = "********"

# ============== 屏幕配置 ==============
SCREEN_WIDTH = 540
SCREEN_HEIGHT = 960

# ============== 颜色定义 ==============
COLOR_BG = 0xFFFFFF
COLOR_WORK = 0x333333
COLOR_BREAK = 0x666666
COLOR_HINT = 0x999999
COLOR_LINE = 0xCCCCCC
COLOR_SELECTED = 0x333333

# ============== 状态常量 ==============
STATE_WORK = 0
STATE_BREAK = 1
STATE_LONG_BREAK = 2
STATE_IDLE = 3
STATE_PAUSED = 4

STATE_NAMES = ["工作中", "短休息", "长休息", "空闲", "已暂停"]

# ============== 可选时长 ==============
TIME_OPTIONS = [5*60, 10*60, 15*60, 20*60, 25*60]

# ============== 布局参数 ==============
TIME_BOX_WIDTH = 80
TIME_BOX_HEIGHT = 80
TIME_BOX_GAP = 20
TIME_BOX_START_X = 30
TIME_BOX_Y = 750  # 时间框靠近底部

# ============== 状态变量 ==============
current_state = STATE_IDLE
remaining_time = 25 * 60
selected_index = 4
start_time = 0
paused_remaining = 0
elapsed_at_pause = 0
pomodoros_completed = 0
last_minute = -1  # 上次刷新时的分钟
last_15s_refresh = -1  # 上次15秒刷新的时间桶(已废弃,用 last_refresh_elapsed 替代)
last_refresh_elapsed = 0  # 上次刷新的相对秒数(已废弃)
refresh_count = 0  # 已刷新的15秒次数
touch_was_pressed = False
last_touch_x = 0
last_touch_y = 0
last_touch_count = 0  # 保存按下时的触摸数量
loop_count = 0

# ============== WiFi和网络 ==============
def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        wlan.disconnect()
        log("WiFi正在连接: %s" % WIFI_SSID)
        wlan.connect(WIFI_SSID, WIFI_PASSWORD)
        timeout = 30
        while not wlan.isconnected() and timeout > 0:
            time.sleep(1)
            timeout -= 1
    if wlan.isconnected():
        log("WiFi已连接,IP: %s" % wlan.ifconfig()[0])
        return True
    else:
        log("WiFi连接失败")
        return False

def sync_time():
    try:
        ntptime.settime()
        log("NTP时间同步成功")
        return True
    except Exception as e:
        log("NTP时间同步失败: %s" % str(e))
        return False

def log(msg):
    """带时间戳的日志输出"""
    t = time.localtime()
    # 替换selected显示
    msg = msg.replace("selected=%d(%s秒)" % (selected_index, TIME_OPTIONS[selected_index]),
                      "selected=%d(%d分钟)" % (selected_index, TIME_OPTIONS[selected_index] // 60))
    print("%04d-%02d-%02d %02d:%02d:%02d [APP] %s" % (t[0], t[1], t[2], t[3], t[4], t[5], msg))

# ============== 工具函数 ==============
def format_time(seconds):
    mins = seconds // 60
    secs = seconds % 60
    return "{:02d}:{:02d}".format(mins, secs)

def get_display_time():
    """获取当前剩余时间"""
    if start_time == 0:
        return remaining_time
    elapsed = time.time() - start_time
    t = remaining_time - elapsed
    if t < 0:
        return 0
    return int(t)  # 直接返回秒数

def get_15s_bucket():
    """获取当前15秒的时间桶"""
    return int(time.time() // 15)

def get_minute_bucket():
    """获取当前分钟桶(从epoch开始的分钟)"""
    return int(time.time() // 60)

# ============== 绘制函数 ==============
def draw_background():
    M5.Lcd.clear(COLOR_BG)

def draw_timer():
    """绘制计时器区域"""
    if current_state == STATE_WORK:
        state_text = "专注工作中"
        color = COLOR_WORK
    elif current_state == STATE_BREAK:
        state_text = "休息一下"
        color = COLOR_BREAK
    elif current_state == STATE_LONG_BREAK:
        state_text = "长休息"
        color = COLOR_BREAK
    elif current_state == STATE_PAUSED:
        state_text = "已暂停"
        color = COLOR_BREAK
    else:
        state_text = "番茄钟"
        color = COLOR_WORK

    # 状态文字
    M5.Lcd.setFont(M5.Lcd.FONTS.EFontCN24)
    M5.Lcd.setTextColor(color, COLOR_BG)
    state_width = len(state_text) * 24
    x = int((SCREEN_WIDTH - state_width) // 2)
    M5.Lcd.setCursor(x, 60)
    M5.Lcd.print(state_text)

    # 大时间显示 - DejaVu72
    display_time = get_display_time()
    time_str = format_time(display_time)
    M5.Lcd.setFont(M5.Lcd.FONTS.DejaVu72)
    M5.Lcd.setTextColor(COLOR_WORK, COLOR_BG)
    x = int((SCREEN_WIDTH - 260) // 2)
    M5.Lcd.setCursor(x, 240)
    M5.Lcd.print(time_str)

def draw_progress():
    """绘制进度指示"""
    if current_state != STATE_WORK:
        return

    progress = 1 - get_display_time() / TIME_OPTIONS[selected_index]
    if progress < 0:
        progress = 0
    total_dots = 8
    filled_dots = int(progress * total_dots)
    dots_y = 390
    dots_start_x = 150

    M5.Lcd.setFont(M5.Lcd.FONTS.DejaVu24)
    for i in range(total_dots):
        dot_x = dots_start_x + i * 30
        if i < filled_dots:
            M5.Lcd.fillCircle(dot_x, dots_y, 6, COLOR_WORK)
        else:
            M5.Lcd.drawCircle(dot_x, dots_y, 6, COLOR_LINE)

def draw_pomodoros():
    """绘制番茄计数"""
    M5.Lcd.setFont(M5.Lcd.FONTS.EFontCN24)
    M5.Lcd.setTextColor(COLOR_BREAK, COLOR_BG)
    text = "已完成 {} 个番茄".format(pomodoros_completed)
    text_width = len(text) * 24
    x = int((SCREEN_WIDTH - text_width) // 2)
    M5.Lcd.setCursor(x, 550)
    M5.Lcd.print(text)

def draw_divider():
    """绘制分隔线"""
    line_y = 620
    M5.Lcd.drawLine(30, line_y, SCREEN_WIDTH - 30, line_y, COLOR_LINE)

def draw_time_options():
    """绘制时间选项框"""
    box_y = TIME_BOX_Y
    for i, secs in enumerate(TIME_OPTIONS):
        box_x = TIME_BOX_START_X + i * (TIME_BOX_WIDTH + TIME_BOX_GAP)
        is_selected = (i == selected_index)

        if is_selected:
            M5.Lcd.fillRect(box_x, box_y, TIME_BOX_WIDTH, TIME_BOX_HEIGHT, COLOR_SELECTED)
            M5.Lcd.setTextColor(COLOR_BG, COLOR_SELECTED)
        else:
            M5.Lcd.fillRect(box_x, box_y, TIME_BOX_WIDTH, TIME_BOX_HEIGHT, COLOR_BG)
            M5.Lcd.drawRect(box_x, box_y, TIME_BOX_WIDTH, TIME_BOX_HEIGHT, COLOR_WORK)
            M5.Lcd.setTextColor(COLOR_WORK, COLOR_BG)

        M5.Lcd.setFont(M5.Lcd.FONTS.DejaVu40)
        num_text = str(secs // 60)
        num_width = len(num_text) * 30
        num_x = box_x + (TIME_BOX_WIDTH - num_width) // 2
        M5.Lcd.setCursor(num_x, box_y + 5)
        M5.Lcd.print(num_text)

        M5.Lcd.setFont(M5.Lcd.FONTS.EFontCN24)
        min_text = "分钟"
        min_width = len(min_text) * 24
        min_x = box_x + (TIME_BOX_WIDTH - min_width) // 2
        M5.Lcd.setCursor(min_x, box_y + 45)
        M5.Lcd.print(min_text)

def draw_hint():
    """绘制提示文字"""
    M5.Lcd.setFont(M5.Lcd.FONTS.EFontCN24)
    M5.Lcd.setTextColor(COLOR_HINT, COLOR_BG)

    if current_state == STATE_IDLE:
        hint = "点击时间框,开始计时"
    elif current_state == STATE_WORK:
        hint = "双指点击暂停"
    elif current_state == STATE_PAUSED:
        hint = "双指点击继续"
    else:
        hint = "双指点击停止"

    text_width = len(hint) * 20
    x = int((SCREEN_WIDTH - text_width) // 2)
    M5.Lcd.setCursor(x, 880)
    M5.Lcd.print(hint)

def draw_all():
    log(">>> 全屏刷新 <<<")
    draw_background()
    draw_timer()
    draw_progress()
    draw_pomodoros()
    draw_divider()
    draw_time_options()
    draw_hint()

def partial_refresh_time_progress():
    """局部刷新时间和进度(每15秒自动刷新)"""
    display_time = get_display_time()
    log(">>> 局部刷新时间 <<< remaining=%s" % format_time(display_time))

    # 只刷新时间和进度
    M5.Lcd.fillRect(140, 240, 260, 90, COLOR_BG)
    time_str = format_time(display_time)
    M5.Lcd.setFont(M5.Lcd.FONTS.DejaVu72)
    M5.Lcd.setTextColor(COLOR_WORK, COLOR_BG)
    x = int((SCREEN_WIDTH - 260) // 2)
    M5.Lcd.setCursor(x, 240)
    M5.Lcd.print(time_str)
    partial_refresh_progress()

def partial_refresh_minimal():
    """最小局部刷新(暂停/继续时调用):顶部状态+时间+底部提示"""
    display_time = get_display_time()
    log(">>> 最小局部刷新 <<< remaining=%s" % format_time(display_time))

    # 刷新顶部状态文字
    if current_state == STATE_WORK:
        state_text = "专注工作中"
        color = COLOR_WORK
    elif current_state == STATE_PAUSED:
        state_text = "已暂停"
        color = COLOR_BREAK
    else:
        state_text = "番茄钟"
        color = COLOR_WORK

    M5.Lcd.fillRect(0, 60, SCREEN_WIDTH, 50, COLOR_BG)
    M5.Lcd.setFont(M5.Lcd.FONTS.EFontCN24)
    M5.Lcd.setTextColor(color, COLOR_BG)
    state_width = len(state_text) * 24
    x = int((SCREEN_WIDTH - state_width) // 2)
    M5.Lcd.setCursor(x, 60)
    M5.Lcd.print(state_text)

    # 刷新时间显示
    M5.Lcd.fillRect(140, 240, 260, 90, COLOR_BG)
    time_str = format_time(display_time)
    M5.Lcd.setFont(M5.Lcd.FONTS.DejaVu72)
    M5.Lcd.setTextColor(COLOR_WORK, COLOR_BG)
    x = int((SCREEN_WIDTH - 260) // 2)
    M5.Lcd.setCursor(x, 240)
    M5.Lcd.print(time_str)

    # 刷新底部提示文字
    M5.Lcd.fillRect(0, 870, SCREEN_WIDTH, 40, COLOR_BG)
    M5.Lcd.setFont(M5.Lcd.FONTS.EFontCN24)
    M5.Lcd.setTextColor(COLOR_HINT, COLOR_BG)
    if current_state == STATE_PAUSED:
        hint = "双指点击继续"
    else:
        hint = "双指点击暂停"
    text_width = len(hint) * 20
    x = int((SCREEN_WIDTH - text_width) // 2)
    M5.Lcd.setCursor(x, 880)
    M5.Lcd.print(hint)

def partial_refresh_all():
    """完整局部刷新(进入计时时调用)"""
    display_time = get_display_time()
    log(">>> 完整局部刷新 <<< remaining=%s" % format_time(display_time))

    # 刷新顶部状态文字
    if current_state == STATE_WORK:
        state_text = "专注工作中"
        color = COLOR_WORK
    elif current_state == STATE_PAUSED:
        state_text = "已暂停"
        color = COLOR_BREAK
    else:
        state_text = "番茄钟"
        color = COLOR_WORK

    M5.Lcd.fillRect(0, 60, SCREEN_WIDTH, 50, COLOR_BG)
    M5.Lcd.setFont(M5.Lcd.FONTS.EFontCN24)
    M5.Lcd.setTextColor(color, COLOR_BG)
    state_width = len(state_text) * 24
    x = int((SCREEN_WIDTH - state_width) // 2)
    M5.Lcd.setCursor(x, 60)
    M5.Lcd.print(state_text)

    # 刷新时间显示
    M5.Lcd.fillRect(140, 240, 260, 90, COLOR_BG)
    time_str = format_time(display_time)
    M5.Lcd.setFont(M5.Lcd.FONTS.DejaVu72)
    M5.Lcd.setTextColor(COLOR_WORK, COLOR_BG)
    x = int((SCREEN_WIDTH - 260) // 2)
    M5.Lcd.setCursor(x, 240)
    M5.Lcd.print(time_str)
    partial_refresh_progress()

    # 刷新底部提示文字
    M5.Lcd.fillRect(0, 870, SCREEN_WIDTH, 40, COLOR_BG)
    M5.Lcd.setFont(M5.Lcd.FONTS.EFontCN24)
    M5.Lcd.setTextColor(COLOR_HINT, COLOR_BG)
    if current_state == STATE_PAUSED:
        hint = "双指点击继续"
    else:
        hint = "双指点击暂停"
    text_width = len(hint) * 20
    x = int((SCREEN_WIDTH - text_width) // 2)
    M5.Lcd.setCursor(x, 880)
    M5.Lcd.print(hint)

    # 刷新时间框选中状态
    box_y = TIME_BOX_Y
    for i, secs in enumerate(TIME_OPTIONS):
        box_x = TIME_BOX_START_X + i * (TIME_BOX_WIDTH + TIME_BOX_GAP)
        is_selected = (i == selected_index)

        if is_selected:
            M5.Lcd.fillRect(box_x, box_y, TIME_BOX_WIDTH, TIME_BOX_HEIGHT, COLOR_SELECTED)
            M5.Lcd.setTextColor(COLOR_BG, COLOR_SELECTED)
        else:
            M5.Lcd.fillRect(box_x, box_y, TIME_BOX_WIDTH, TIME_BOX_HEIGHT, COLOR_BG)
            M5.Lcd.drawRect(box_x, box_y, TIME_BOX_WIDTH, TIME_BOX_HEIGHT, COLOR_WORK)
            M5.Lcd.setTextColor(COLOR_WORK, COLOR_BG)

        M5.Lcd.setFont(M5.Lcd.FONTS.DejaVu40)
        num_text = str(secs // 60)
        num_width = len(num_text) * 30
        num_x = box_x + (TIME_BOX_WIDTH - num_width) // 2
        M5.Lcd.setCursor(num_x, box_y + 5)
        M5.Lcd.print(num_text)

        M5.Lcd.setFont(M5.Lcd.FONTS.EFontCN24)
        min_text = "分钟"
        min_width = len(min_text) * 24
        min_x = box_x + (TIME_BOX_WIDTH - min_width) // 2
        M5.Lcd.setCursor(min_x, box_y + 45)
        M5.Lcd.print(min_text)

def partial_refresh_progress():
    """局部刷新进度"""
    if current_state != STATE_WORK:
        return
    M5.Lcd.fillRect(140, 380, 260, 30, COLOR_BG)
    progress = 1 - get_display_time() / TIME_OPTIONS[selected_index]
    if progress < 0:
        progress = 0
    total_dots = 8
    filled_dots = int(progress * total_dots)
    dots_y = 390
    dots_start_x = 150
    M5.Lcd.setFont(M5.Lcd.FONTS.DejaVu24)
    for i in range(total_dots):
        dot_x = dots_start_x + i * 30
        if i < filled_dots:
            M5.Lcd.fillCircle(dot_x, dots_y, 6, COLOR_WORK)
        else:
            M5.Lcd.drawCircle(dot_x, dots_y, 6, COLOR_LINE)

# ============== 触摸处理 ==============
def is_in_time_box(x, y):
    """检查坐标是否在时间框区域内"""
    for i in range(len(TIME_OPTIONS)):
        box_x = TIME_BOX_START_X + i * (TIME_BOX_WIDTH + TIME_BOX_GAP)
        if (box_x <= x <= box_x + TIME_BOX_WIDTH and
            TIME_BOX_Y <= y <= TIME_BOX_Y + TIME_BOX_HEIGHT):
            return True
    return False

def check_time_option_touch(x, y):
    """检查是否点击了时间选项"""
    global selected_index

    for i in range(len(TIME_OPTIONS)):
        box_x = TIME_BOX_START_X + i * (TIME_BOX_WIDTH + TIME_BOX_GAP)
        if (box_x <= x <= box_x + TIME_BOX_WIDTH and
            TIME_BOX_Y <= y <= TIME_BOX_Y + TIME_BOX_HEIGHT):
            log("点击选项框 %d (%s秒)" % (i, TIME_OPTIONS[i]))
            if selected_index != i:
                old_index = selected_index
                selected_index = i
                log("选项变化: %d -> %d" % (old_index, selected_index))
                return True
            else:
                log("点击当前选中项,无变化")
                return False
    log("未点击选项框")
    return False

def handle_touch(x, y, count):
    """处理触摸"""
    global last_touch_x, last_touch_y, last_minute

    log("触摸: count=%d x=%d y=%d" % (count, x, y))

    if count == 1:
        # 单指
        if current_state == STATE_IDLE:
            if is_in_time_box(x, y):
                check_time_option_touch(x, y)
                start_work()
            else:
                log("IDLE单指点击非时间框区域,不操作")
        else:
            log("%s状态下单指点击,不操作" % STATE_NAMES[current_state])
    elif count == 2:
        # 双指
        if current_state == STATE_WORK:
            pause_timer()
        elif current_state == STATE_PAUSED:
            resume_timer()
        else:
            stop_timer()

    last_touch_x = x
    last_touch_y = y

def start_work():
    global current_state, remaining_time, start_time, last_minute, last_15s_refresh, refresh_count, elapsed_at_pause
    log("状态变化: IDLE -> WORK | selected=%d(%s)" % (selected_index, TIME_OPTIONS[selected_index] // 60))
    current_state = STATE_WORK
    remaining_time = TIME_OPTIONS[selected_index]
    start_time = time.time()
    last_minute = get_minute_bucket()
    last_15s_refresh = get_15s_bucket()
    refresh_count = 0
    elapsed_at_pause = 0
    # 进入计时,全屏刷新
    draw_all()

def pause_timer():
    global current_state, paused_remaining, start_time, remaining_time, elapsed_at_pause
    if current_state == STATE_WORK:
        paused_remaining = get_display_time()
        elapsed_at_pause = time.time() - start_time  # 保存暂停时的总elapsed
        log("状态变化: WORK -> PAUSED | paused=%s elapsed=%d" % (format_time(paused_remaining), int(elapsed_at_pause)))
        # 先切换状态
        current_state = STATE_PAUSED
        # 再刷新
        partial_refresh_minimal()
        start_time = 0

def resume_timer():
    global current_state, remaining_time, start_time, last_minute, last_15s_refresh, refresh_count, elapsed_at_pause
    if current_state == STATE_PAUSED:
        current_state = STATE_WORK
        # 恢复时保持remaining_time为原始值,start_time向后偏移
        # 这样get_display_time() = remaining_time - (elapsed_at_pause + new_elapsed)
        start_time = time.time() - elapsed_at_pause
        last_minute = get_minute_bucket()
        last_15s_refresh = get_15s_bucket()
        log("状态变化: PAUSED -> WORK | remaining=%s elapsed=%d refresh_count=%d" % (format_time(get_display_time()), int(elapsed_at_pause), refresh_count))
        # 继续时最小局部刷新
        partial_refresh_minimal()

def start_break():
    global current_state, remaining_time, start_time, pomodoros_completed, last_minute, last_15s_refresh, refresh_count
    pomodoros_completed += 1
    log("状态变化: WORK -> BREAK | pomodoros=%d" % pomodoros_completed)

    if pomodoros_completed % 4 == 0:
        log("进入长休息(15分钟)")
        current_state = STATE_LONG_BREAK
        remaining_time = 15 * 60
    else:
        log("进入短休息(5分钟)")
        current_state = STATE_BREAK
        remaining_time = 5 * 60

    start_time = time.time()
    last_minute = get_minute_bucket()
    last_15s_refresh = get_15s_bucket()
    refresh_count = 0
    last_refresh_elapsed = 0
    # 完成番茄后进入休息,全屏刷新
    draw_all()

def stop_timer():
    global current_state, remaining_time, start_time, paused_remaining
    log("状态变化: %s -> IDLE" % STATE_NAMES[current_state])
    current_state = STATE_IDLE
    remaining_time = TIME_OPTIONS[selected_index]
    start_time = 0
    paused_remaining = 0
    draw_all()

# ============== 主循环 ==============
def setup():
    global last_minute, last_15s_refresh, last_touch_x, last_touch_y, last_touch_count
    M5.begin()
    M5.Lcd.setEpdMode(M5.Lcd.EPDMode.EPD_FAST)

    if connect_wifi():
        sync_time()

    remaining_time = TIME_OPTIONS[selected_index]
    last_minute = get_minute_bucket()
    last_15s_refresh = get_15s_bucket()
    refresh_count = 0
    last_touch_x = 0
    last_touch_y = 0
    last_touch_count = 0

    log("番茄钟初始化完成")
    log("初始状态: %s selected=%d(%s秒)" % (STATE_NAMES[current_state], selected_index, TIME_OPTIONS[selected_index]))

    draw_all()

def loop():
    global loop_count, last_minute, last_15s_refresh, refresh_count, touch_was_pressed, last_touch_x, last_touch_y, last_touch_count

    loop_count += 1
    M5.update()

    touch_count = M5.Touch.getCount()

    if touch_count > 0:
        touch = M5.Touch
        x = touch.getX()
        y = touch.getY()
        if x > 0 or y > 0:
            if not touch_was_pressed:
                log("触摸按下: count=%d x=%d y=%d" % (touch_count, x, y))
                touch_was_pressed = True
                last_touch_x = x
                last_touch_y = y
                last_touch_count = touch_count  # 保存按下时的触摸数量
    elif touch_was_pressed:
        log("触摸释放: count=%d x=%d y=%d" % (last_touch_count, last_touch_x, last_touch_y))
        touch_was_pressed = False
        handle_touch(last_touch_x, last_touch_y, last_touch_count)  # 使用保存的触摸数量
        last_touch_x = 0
        last_touch_y = 0
        last_touch_count = 0

    # 计时中的刷新逻辑
    if current_state == STATE_WORK or current_state == STATE_BREAK or current_state == STATE_LONG_BREAK:
        elapsed = time.time() - start_time
        display_time = get_display_time()
        # 刷新时刻:remaining = 285, 270, 255, 240, ... (即 remaining_time - n*15)
        # 刷新条件:display_time <= remaining_time - (refresh_count + 1) * 15
        next_refresh_threshold = remaining_time - (refresh_count + 1) * 15

        if display_time <= next_refresh_threshold and display_time > 0:
            if elapsed % 60 == 0 and elapsed > 0:
                # 整分钟时全屏刷新
                log("%d秒到,全屏刷新" % int(elapsed))
                draw_all()
            else:
                # 15秒、30秒、45秒时局部刷新
                log("%d秒到,局部刷新 remaining=%s" % (int(elapsed), format_time(display_time)))
                partial_refresh_time_progress()
            refresh_count += 1

    if loop_count <= 3 or loop_count % 50 == 0:
        log("循环#%d state=%s" % (loop_count, STATE_NAMES[current_state]))

    # 检查计时完成
    if current_state == STATE_WORK:
        if get_display_time() <= 0:
            log("计时完成,开始休息")
            start_break()
    elif current_state == STATE_BREAK or current_state == STATE_LONG_BREAK:
        if get_display_time() <= 0:
            log("休息结束,停止")
            stop_timer()

    time.sleep_ms(100)

if __name__ == "__main__":
    setup()
    while True:
        loop()


运行效果空闲状态

02_pomodoro_idle.png

计时中

02_pomodoro_working.png

已暂停

02_pomodoro_paused.png

休息中

02_pomodoro_break.png

技术要点总结

  1. 墨水屏刷新策略是灵魂

    • 全屏刷新:状态改变时

    • 局部刷新:每15秒

    • 最小刷新:暂停/继续时

  2. 基于 remaining 计算刷新时刻

    • 刷新点:285秒、270秒、255秒...

    • 这样暂停/继续后刷新节奏不会乱

  3. start_time 偏移实现暂停继续

    • start_time = time.time() - elapsed_at_pause

    • 保持 elapsed 的数学连续性

  4. 触摸检测用释放事件

    • 避免按下时误判

    • 保存按下时的触摸数量

  5. WiFi 连接要加超时

    • 否则连不上会卡死

    • NTP 同步也要 try-except

适用场景

  • 专注工作/学习

  • 远程办公时间管理

  • 考试复习倒计时

  • 厨房烹饪计时


好了,番茄钟的开发经验就分享到这里。




关键词: M5PAPERESP32EINKDEVKIT     评测    

高工
2026-03-27 07:34:49     打赏
2楼

帮主的创意都是那有艺术水平!


菜鸟
2026-03-27 10:10:17     打赏
3楼

帮主真高产啊


专家
2026-03-28 07:36:51     打赏
4楼

谢谢楼主分享


共4条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]