2026-6-8 17:22:43 [显示全部楼层]
8浏览
查看: 8|回复: 0

[Micropython] C4002 毫米波雷达——老人卫生间长时间静止报警系统

[复制链接]
基于 MicroPython 与 DFRobot SEN0691 C4002 毫米波雷达的人体静止报警项目报告一、项目名称
老人卫生间长时间静止报警系统
二、真实痛点与应用背景
老人独居或夜间如厕时,卫生间是跌倒高风险场景。传统 PIR 红外人体传感器只能对明显肢体运动敏感,当老人跌倒后长时间静止、坐在地面微动、或因眩晕无法主动呼救时,PIR 很容易误判为“无人”。摄像头虽然可识别姿态,但卫生间属于强隐私空间,不适合部署图像采集设备。
本项目使用 DFRobot SEN0691 C4002 mmWave Motion and Static Presence Module,通过毫米波雷达检测“运动人体”和“静止/微动人体”,并结合 ESP32 MicroPython 实现本地报警。系统不拍摄图像,不上传人体画面,适合卫生间、卧室、养老公寓等隐私敏感区域。
三、项目目标
本项目解决的核心问题是:当卫生间内检测到有人,但连续较长时间只有静止/微动、无明显运动时,系统先发出预警提示;若无人按键取消,则触发蜂鸣器和继电器报警。
设计目标如下:
1. 能识别“无人、有人静止/微动、有人运动”三类状态。
2. 避免 PIR 对静止人体漏检的问题。
3. 限定检测距离,减少卫生间门外、走廊、隔墙人体造成的误触发。
4. 设置“预报警—正式报警”两级机制,避免老人正常久坐时直接惊扰。
5. 通过取消按钮手动解除报警,体现真实场景可用性。
6. 全部核心逻辑在 ESP32 本地运行,断网也能报警。
四、核心硬件
序号
器件
数量
作用
1
ESP32 DevKit,烧录 MicroPython
1
主控,读取 C4002 UART 数据并执行报警逻辑
2
DFRobot SEN0691 C4002 毫米波模块
1
人体运动/静止/微动检测
3
有源蜂鸣器模块或小型声光报警器
1
本地预警与正式报警
4
1 路继电器模块,低电平或高电平触发均可按代码修改
1
控制外接声光报警器或低压警示灯
5
LED + 220Ω 电阻
1
状态指示
6
轻触按键
1
取消报警/确认安全
7
5V 电源适配器
1
ESP32 C4002 供电
8
面包板/杜邦线/防水外壳
若干
安装与调试
注意:如果继电器控制 220V 市电灯具,必须由具备电工资质人员处理,弱电系统与强电必须隔离。课堂或原型验证建议只接低压蜂鸣器或 12V 警示灯。
五、传感器能力说明
C4002 是基于 24GHz FMCW 技术的毫米波人体存在传感器。与传统 PIR 相比,它不仅能检测人走动时的大幅运动,也能检测人体静止时由呼吸、轻微晃动造成的微动信号。模块通过 UART 输出目标状态、存在距离、运动距离、运动速度、运动方向、目标能量和环境光照等数据。
本项目使用 UART 模式,不只读取 OUT 高低电平,因为 UART 能得到更丰富的数据,例如:
· target:近、远离或无方向。无人、静止/微动、运动;
· presence_distance_m:静止/微动目标距离;
· motion_distance_m:运动目标距离;
· presence_energy / motion_energy:目标能量;
· light_lux:环境光照;
· motion_speed_mps:运动速度;
· motion_direction:靠
这些信息可用于过滤门外人员、排除弱干扰、判断是否长时间静止。
六、安装位置与检测区域
推荐安装方式:
· 卫生间面积:约 1.5m × 2.5m;
· 传感器安装在门内侧上方或侧墙,高度约 1.8–2.2m;
· 雷达正面朝向马桶、淋浴区与地面活动区域;
· 不建议正对金属门、大面积镜子或强反射不锈钢置物架;
· 不建议直接贴近换气扇、浴帘、晾衣杆等频繁摆动目标。
本项目建议检测距离设为 40–240cm。这样可以覆盖卫生间内部人员,又能减少门外走廊人员被毫米波穿透或旁瓣检测到的概率。
七、接线说明7.1 C4002 与 ESP32 接线表
C4002 引脚
ESP32 引脚
说明
VIN
5V / VIN
C4002 供电
GND
GND
共地
TX
GPIO16 / UART1 RX
传感器发送,ESP32 接收
RX
GPIO17 / UART1 TX
传感器接收,ESP32 发送
OUT
GPIO27,可选
可读取 C4002 OUT 输出,本项目主要使用 UART
7.2 报警与按键接线表
外设
ESP32 引脚
说明
LED 正极,经 220Ω 电阻
GPIO2
状态灯
LED 负极
GND
接地
有源蜂鸣器 IN
GPIO25
高电平响
有源蜂鸣器 VCC
3.3V 5V,按模块要求
供电
有源蜂鸣器 GND
GND
共地
继电器 IN
GPIO26
高电平触发,若模块低电平触发需改代码
继电器 VCC
5V
供电
继电器 GND
GND
共地
取消按钮一端
GPIO14
内部上拉输入
取消按钮另一端
GND
按下为低电平
7.3 ASCII 接线图
                 ┌──────────────────────┐
                 │       ESP32 DevKit    │
                 │                      │
5V  ─────────────┤ 5V/VIN                │
GND ─────────────┤ GND                   │
                 │                      │
GPIO17 / TX1 ────┤ UART1 TX              │
GPIO16 / RX1 ────┤ UART1 RX              │
GPIO27  ◄────────┤ 可选 OUT 输入         
                 │                      │
GPIO25 ─────────►│ 蜂鸣器 IN             │
GPIO26 ─────────►│ 继电器 IN             │
GPIO2  ─────────►│ LED + 220Ω            │
GPIO14 ◄─────────┤ 取消按钮,另一端接 GND │
                 └──────────────────────┘
                         ▲       ▲
                         │       │
                         │       │
                 ┌───────┴───────┴──────┐
                 │   DFRobot C4002       │
                 │                       │
                 │ VIN  ◄──── 5V         │
                 │ GND  ◄──── GND        │
                 │ RX   ◄──── ESP32 TX17 │
                 │ TX   ────► ESP32 RX16 │
                 │ OUT  ────► ESP32 GPIO27,可选
                 └───────────────────────┘
八、系统逻辑
系统将卫生间状态分为五个状态:
1. IDLE:无人
2. ACTIVE:有人且有运动
3. STATIC_WATCH:有人静止/微动,开始计时
4. PREALARM:超过静止阈值,短促蜂鸣提醒
5. ALARM:预报警期间无人取消,继电器与蜂鸣器正式报警
核心判断逻辑:
· 如果 C4002 输出 MOTION,说明有明显运动,重置静止计时。
· 如果 C4002 输出 PRESENCE,说明检测到静止/微动人体。
· 若持续 PRESENCE 且超过 8 分钟没有 MOTION,进入预报警。
· 预报警持续 30 秒,蜂鸣器间歇响,提醒老人按下取消按钮。
· 若 30 秒内按下取消按钮,则静默 10 分钟。
· 若无人按键取消,则进入正式报警,继电器和蜂鸣器启动。
· 如果检测为 NO_TARGET,说明卫生间无人,所有报警状态复位。
九、流程图C4002 毫米波雷达——老人卫生间长时间静止报警系统图1十、完整 MicroPython 代码
文件名建议保存为 main.py,上传到 ESP32 后复位运行。
# main.py
# ESP32 MicroPython + DFRobot SEN0691 C4002
# 项目:老人卫生间长时间静止报警系统
#
# UART 接线:
# C4002 TX -> ESP32 GPIO16 / UART1 RX
# C4002 RX -> ESP32 GPIO17 / UART1 TX
# C4002 VIN -> 5V
# C4002 GND -> GND
#
# 外设:
# GPIO25 -> 有源蜂鸣器 IN,高电平响
# GPIO26 -> 继电器 IN,高电平触发
# GPIO2  -> LED
# GPIO14 -> 取消按钮,另一端接 GND,内部上拉

from machine import UART, Pin
import time


class C4002:
    # Frame headers
    HEADER = b"\xFA\xF5\xAA\xA5"

    # Frame type
    FRAME_TYPE_WRITE_REQUEST = 0x00
    FRAME_TYPE_READ_REQUEST = 0x01
    FRAME_TYPE_WRITE_RESPOND = 0x02
    FRAME_TYPE_READ_RESPOND = 0x03
    FRAME_TYPE_NOTIFICATION = 0x04

    # Commands
    CMD_SET_LED_MODE = 0xA1
    CMD_CONFIG_OUT_MODE = 0xA0
    CMD_ENV_CALIBRATION = 0x60
    CMD_SET_DETECT_RANGE = 0x86
    CMD_SET_REPORT_PERIOD = 0x83
    CMD_SET_LIGHT_THRESHOLD = 0x88
    CMD_SET_DISTANCE_GATE = 0x62
    CMD_GET_SET_RESOLUTION_MODE = 0x66
    CMD_TARGET_DISAPPEAR_DELAY = 0x84
    CMD_LOCK_TIME = 0x85
    CMD_THRESHOLD_GROUP = 0x87

    # Notification command
    NOTE_RESULT_CMD = 0x60
    NOTE_ENV_CALIBRATION_CMD = 0x03

    # Response code
    SUCCEED = 0x01

    # Target state
    NO_TARGET = 0
    PRESENCE = 1
    MOTION = 2

    # Direction
    AWAY = 0
    NO_DIRECTION = 1
    APPROACHING = 2

    # Resolution
    RESOLUTION_80CM = 0x00
    RESOLUTION_20CM = 0x01

    # Distance gate type
    MOTION_DISTANCE_GATE = 0x00
    PRESENCE_DISTANCE_GATE = 0x01

    # Sensitivity group
    LOW_THRESH_GROUP = 0x00
    MID_THRESH_GROUP = 0x01
    HIGH_THRESH_GROUP = 0x02
    CUSTOM_THRESH_GROUP = 0x03

    # LED
    LED_OFF = 0x00
    LED_ON = 0x01
    LED_KEEP = 0xFF

    # OUT pin mode
    OUT_PIN_MODE_MOTION_ONLY = 0x01
    OUT_PIN_MODE_PRESENCE_ONLY = 0x02
    OUT_PIN_MODE_MOTION_OR_PRESENCE = 0x03

    def __init__(self, uart_id=1, baudrate=115200, tx_pin=17, rx_pin=16):
        self.uart = UART(
            uart_id,
            baudrate=baudrate,
            bits=8,
            parity=None,
            stop=1,
            tx=Pin(tx_pin),
            rx=Pin(rx_pin),
            timeout=0,
            timeout_char=0
        )
        self.resolution_mode = self.RESOLUTION_80CM
        self.last_result = None

    @staticmethod
    def _u16_le(buf, pos):
        return buf[pos] | (buf[pos + 1] << 8)

    @staticmethod
    def _u32_le(buf, pos):
        return (
            buf[pos]
            | (buf[pos + 1] << 8)
            | (buf[pos + 2] << 16)
            | (buf[pos + 3] << 24)
        )

    @staticmethod
    def _i16_le(buf, pos):
        value = buf[pos] | (buf[pos + 1] << 8)
        if value & 0x8000:
            value -= 0x10000
        return value

    def _read_exact(self, n, timeout_ms=300):
        data = bytearray()
        deadline = time.ticks_add(time.ticks_ms(), timeout_ms)
        while len(data) < n and time.ticks_diff(deadline, time.ticks_ms()) > 0:
            chunk = self.uart.read(n - len(data))
            if chunk:
                data.extend(chunk)
            else:
                time.sleep_ms(2)
        if len(data) == n:
            return bytes(data)
        return None

    def _read_frame(self, timeout_ms=500):
        # 1. Find header
        matched = 0
        deadline = time.ticks_add(time.ticks_ms(), timeout_ms)

        while time.ticks_diff(deadline, time.ticks_ms()) > 0:
            b = self.uart.read(1)
            if not b:
                time.sleep_ms(2)
                continue

            value = b[0]
            if value == self.HEADER[matched]:
                matched += 1
                if matched == 4:
                    break
            else:
                matched = 1 if value == self.HEADER[0] else 0

        if matched != 4:
            return None

        # 2. Read length low/high, reserved byte, frame type
        rest = self._read_exact(4, timeout_ms=timeout_ms)
        if rest is None:
            return None

        packet_len = rest[0] | (rest[1] << 8)
        frame_type = rest[3]

        # Basic sanity check. C4002 frames are short; 128 is enough here.
        if packet_len < 12 or packet_len > 128:
            return None

        # 3. Read remaining frame body, including payload and checksum
        remaining = self._read_exact(packet_len - 8, timeout_ms=timeout_ms)
        if remaining is None:
            return None

        frame = self.HEADER + rest + remaining

        rx_sum = frame[-2] | (frame[-1] << 8)
        calc_sum = sum(frame[:-2]) & 0xFFFF
        if rx_sum != calc_sum:
            return None

        payload = frame[8:-2]
        if len(payload) < 4:
            return None

        cmd = payload[0]
        resp_code = payload[1]
        inner_len = payload[2] | (payload[3] << 8)

        # inner_len includes cmd, resp_code, lenL, lenH
        data = payload[4:inner_len]

        return {
            "frame_type": frame_type,
            "cmd": cmd,
            "resp_code": resp_code,
            "data": data,
        }

    def _send_frame(self, cmd, params=b"", frame_type=FRAME_TYPE_WRITE_REQUEST):
        inner_len = 4 + len(params)
        payload = bytearray()
        payload.append(cmd)
        payload.append(0x00)  # READ_AND_WRITE_REQ
        payload.append(inner_len & 0xFF)
        payload.append((inner_len >> 8) & 0xFF)
        payload.extend(params)

        packet_len = len(payload) + 10

        frame = bytearray()
        frame.extend(self.HEADER)
        frame.append(packet_len & 0xFF)
        frame.append((packet_len >> 8) & 0xFF)
        frame.append(0x00)
        frame.append(frame_type)
        frame.extend(payload)

        checksum = sum(frame) & 0xFFFF
        frame.append(checksum & 0xFF)
        frame.append((checksum >> 8) & 0xFF)

        # Clear old bytes before sending config/read command
        while self.uart.any():
            self.uart.read()

        self.uart.write(frame)

    def _command(self, cmd, params=b"", read=False, timeout_ms=600):
        frame_type = self.FRAME_TYPE_READ_REQUEST if read else self.FRAME_TYPE_WRITE_REQUEST
        expect_type = self.FRAME_TYPE_READ_RESPOND if read else self.FRAME_TYPE_WRITE_RESPOND

        self._send_frame(cmd, params=params, frame_type=frame_type)

        deadline = time.ticks_add(time.ticks_ms(), timeout_ms)
        while time.ticks_diff(deadline, time.ticks_ms()) > 0:
            remain = time.ticks_diff(deadline, time.ticks_ms())
            frame = self._read_frame(timeout_ms=remain if remain > 20 else 20)
            if not frame:
                continue

            if frame["cmd"] == cmd and frame["frame_type"] == expect_type:
                return frame["resp_code"] == self.SUCCEED, frame

        return False, None

    def set_report_period(self, period):
        # unit: 0.1 s. 10 means 1 s.
        period = max(0, min(255, int(period)))
        return self._command(
            self.CMD_SET_REPORT_PERIOD,
            params=bytes([period]),
            read=False
        )[0]

    def set_resolution_mode(self, mode):
        ok, _ = self._command(
            self.CMD_GET_SET_RESOLUTION_MODE,
            params=bytes([mode]),
            read=False
        )
        if ok:
            self.resolution_mode = mode
        return ok

    def set_detect_range(self, closest_cm, farthest_cm):
        closest_cm = max(0, int(closest_cm))
        farthest_cm = min(1100, int(farthest_cm))
        if closest_cm > farthest_cm:
            return False

        params = bytes([
            closest_cm & 0xFF,
            (closest_cm >> 8) & 0xFF,
            farthest_cm & 0xFF,
            (farthest_cm >> 8) & 0xFF
        ])
        return self._command(self.CMD_SET_DETECT_RANGE, params=params, read=False)[0]

    def configure_gate(self, gate_type, gate_data):
        gate_num = 15 if self.resolution_mode == self.RESOLUTION_80CM else 25
        if len(gate_data) != gate_num:
            raise ValueError("gate_data length must be %d" % gate_num)

        params = bytearray()
        params.append(gate_type)
        params.extend([1 if x else 0 for x in gate_data])
        return self._command(self.CMD_SET_DISTANCE_GATE, params=bytes(params), read=False)[0]

    def set_light_threshold(self, lux):
        # 0 means detection is not limited by light level
        lux = max(0, min(50, float(lux)))
        v = int(lux * 10)
        params = bytes([v & 0xFF, (v >> 8) & 0xFF])
        return self._command(self.CMD_SET_LIGHT_THRESHOLD, params=params, read=False)[0]

    def set_target_disappear_delay(self, seconds):
        seconds = max(0, min(65535, int(seconds)))
        params = bytes([seconds & 0xFF, (seconds >> 8) & 0xFF])
        return self._command(self.CMD_TARGET_DISAPPEAR_DELAY, params=params, read=False)[0]

    def set_lock_time(self, seconds):
        # range normally 0.2–10.0 s, unit 0.1 s
        seconds = max(0.2, min(10.0, float(seconds)))
        v = int(seconds * 10)
        params = bytes([v & 0xFF, (v >> 8) & 0xFF])
        return self._command(self.CMD_LOCK_TIME, params=params, read=False)[0]

    def set_sensitivity(self, gate_type, sensitivity_group):
        params = bytes([gate_type, sensitivity_group])
        return self._command(self.CMD_THRESHOLD_GROUP, params=params, read=False)[0]

    def set_out_pin_mode(self, mode):
        return self._command(self.CMD_CONFIG_OUT_MODE, params=bytes([mode]), read=False)[0]

    def set_run_led_state(self, state):
        params = bytes([state, self.LED_KEEP])
        return self._command(self.CMD_SET_LED_MODE, params=params, read=False)[0]

    def set_out_led_state(self, state):
        params = bytes([self.LED_KEEP, state])
        return self._command(self.CMD_SET_LED_MODE, params=params, read=False)[0]

    def start_env_calibration(self, delay_s=10, calibration_s=30):
        # 调试时使用。正式运行时不要每次上电都自动校准,避免有人在场时校准出错。
        params = bytes([
            delay_s & 0xFF,
            (delay_s >> 8) & 0xFF,
            calibration_s & 0xFF,
            (calibration_s >> 8) & 0xFF,
            0x01
        ])
        self._command(self.CMD_ENV_CALIBRATION, params=params, read=False, timeout_ms=1000)

    def read_notification(self, timeout_ms=300):
        frame = self._read_frame(timeout_ms=timeout_ms)
        if not frame:
            return None

        if frame["frame_type"] != self.FRAME_TYPE_NOTIFICATION:
            return None

        if frame["resp_code"] != self.SUCCEED:
            return None

        cmd = frame["cmd"]
        data = frame["data"]

        if cmd == self.NOTE_RESULT_CMD:
            if len(data) < 18:
                return None

            target = data[0]
            light_lux = self._u16_le(data, 1) * 0.1

            result = {
                "type": "result",
                "target": target,
                "target_name": self.target_name(target),
                "light_lux": light_lux,
                "presence_gate_index": self._u32_le(data, 3),
                "presence_countdown_s": self._u16_le(data, 7),
                "presence_distance_m": self._u16_le(data, 9) * 0.01,
                "presence_energy": data[11],
                "motion_distance_m": self._u16_le(data, 12) * 0.01,
                "motion_speed_mps": self._i16_le(data, 14) * 0.01,
                "motion_energy": data[16],
                "motion_direction": data[17],
                "motion_direction_name": self.direction_name(data[17]),
            }
            self.last_result = result
            return result

        if cmd == self.NOTE_ENV_CALIBRATION_CMD and len(data) >= 2:
            return {
                "type": "calibration",
                "calibration_countdown_s": self._u16_le(data, 0)
            }

        return None

    def target_name(self, target):
        if target == self.NO_TARGET:
            return "NO_TARGET"
        if target == self.PRESENCE:
            return "PRESENCE_STATIC"
        if target == self.MOTION:
            return "MOTION"
        return "UNKNOWN"

    def direction_name(self, direction):
        if direction == self.AWAY:
            return "AWAY"
        if direction == self.NO_DIRECTION:
            return "NO_DIRECTION"
        if direction == self.APPROACHING:
            return "APPROACHING"
        return "UNKNOWN"


# ========== 用户可调参数 ==========

UART_ID = 1
UART_TX_PIN = 17
UART_RX_PIN = 16
UART_BAUD = 115200

LED_PIN = 2
BUZZER_PIN = 25
RELAY_PIN = 26
CANCEL_BUTTON_PIN = 14

# 继电器触发电平。大多数模块高电平触发;若你的模块低电平触发,改为 0
RELAY_ACTIVE_LEVEL = 1

# 卫生间有效检测距离,单位 m
VALID_MIN_DISTANCE_M = 0.40
VALID_MAX_DISTANCE_M = 2.40

# 静止多久进入预报警。演示可改成 30;真实项目建议 6~12 分钟。
STATIC_ALARM_SECONDS = 8 * 60

# 预报警持续时间。期间老人可按取消按钮。
PREALARM_SECONDS = 30

# 按下取消后静默时间,避免正常久坐反复报警。
CANCEL_MUTE_SECONDS = 10 * 60

# 能量下限。过低能量可能来自边缘、门外或弱干扰;不同卫生间需实测调整。
MIN_PRESENCE_ENERGY = 5
MIN_MOTION_ENERGY = 5

# C4002 上报周期:10 × 0.1s = 1s
REPORT_PERIOD = 10


# ========== GPIO 初始化 ==========

led = Pin(LED_PIN, Pin.OUT)
buzzer = Pin(BUZZER_PIN, Pin.OUT)
relay = Pin(RELAY_PIN, Pin.OUT)
cancel_btn = Pin(CANCEL_BUTTON_PIN, Pin.IN, Pin.PULL_UP)


def relay_on():
    relay.value(RELAY_ACTIVE_LEVEL)


def relay_off():
    relay.value(0 if RELAY_ACTIVE_LEVEL else 1)


def all_alarm_off():
    buzzer.value(0)
    relay_off()


def is_button_pressed():
    # 简单消抖
    if cancel_btn.value() == 0:
        time.sleep_ms(20)
        return cancel_btn.value() == 0
    return False


def beep_prealarm(now_ms):
    # 慢速间歇蜂鸣:0.5s / 0.5s
    buzzer.value(1 if (now_ms // 500) % 2 == 0 else 0)


def beep_alarm(now_ms):
    # 快速蜂鸣
    buzzer.value(1 if (now_ms // 150) % 2 == 0 else 0)


def in_valid_zone(info):
    if info["target"] == C4002.MOTION:
        distance = info["motion_distance_m"]
        energy = info["motion_energy"]
        return (
            VALID_MIN_DISTANCE_M <= distance <= VALID_MAX_DISTANCE_M
            and energy >= MIN_MOTION_ENERGY
        )

    if info["target"] == C4002.PRESENCE:
        distance = info["presence_distance_m"]
        energy = info["presence_energy"]
        return (
            VALID_MIN_DISTANCE_M <= distance <= VALID_MAX_DISTANCE_M
            and energy >= MIN_PRESENCE_ENERGY
        )

    return False


# ========== C4002 初始化 ==========

radar = C4002(
    uart_id=UART_ID,
    baudrate=UART_BAUD,
    tx_pin=UART_TX_PIN,
    rx_pin=UART_RX_PIN
)

print("Booting C4002 elderly bathroom static alarm...")

# 上电稳定
time.sleep_ms(800)

# 先尝试配置;若失败,继续重试,便于调试接线。
while not radar.set_report_period(REPORT_PERIOD):
    print("C4002 set_report_period failed, check UART wiring...")
    time.sleep(1)

# 小卫生间使用 20cm 分辨率,有利于更精细地限制区域。
if radar.set_resolution_mode(C4002.RESOLUTION_20CM):
    print("Resolution: 20cm")
else:
    print("Set resolution failed; continue with current mode.")

# 有效距离限制:40cm ~ 240cm
if radar.set_detect_range(
    int(VALID_MIN_DISTANCE_M * 100),
    int(VALID_MAX_DISTANCE_M * 100)
):
    print("Detect range configured.")
else:
    print("Set detect range failed.")

# 光照阈值设为 0,表示不因亮度禁止人体检测。
radar.set_light_threshold(0)

# 目标消失延时设为 3s,避免短时丢帧导致状态抖动。
radar.set_target_disappear_delay(3)

# 锁定时间略短,减少无人到有人状态切换后的盲区。
radar.set_lock_time(0.5)

# OUT 引脚配置为运动或存在均输出。项目主逻辑使用 UART,这里保留给外部联动。
radar.set_out_pin_mode(C4002.OUT_PIN_MODE_MOTION_OR_PRESENCE)

# 关闭模块板载 LED,减少夜间卫生间光污染;调试时可改为 LED_ON
radar.set_run_led_state(C4002.LED_OFF)
radar.set_out_led_state(C4002.LED_OFF)

# 启用全部 20cm 距离门。若要屏蔽门外区域,可把某些 gate 改为 0
gate_20cm_all_enabled = [1] * 25
radar.configure_gate(C4002.MOTION_DISTANCE_GATE, gate_20cm_all_enabled)
radar.configure_gate(C4002.PRESENCE_DISTANCE_GATE, gate_20cm_all_enabled)

# 环境底噪采集:
# 首次安装调试时可手动取消下面注释,运行一次。
# 启动后 10s 内离开卫生间,等待 30s 完成环境采集。
# 完成后建议重新注释,避免每次上电有人在场时采集错误。
# radar.start_env_calibration(delay_s=10, calibration_s=30)

print("System ready.")


# ========== 状态机 ==========

STATE_IDLE = "IDLE"
STATE_ACTIVE = "ACTIVE"
STATE_STATIC_WATCH = "STATIC_WATCH"
STATE_PREALARM = "PREALARM"
STATE_ALARM = "ALARM"
STATE_CANCELLED = "CANCELLED"

state = STATE_IDLE
presence_since_ms = None
last_motion_ms = None
prealarm_since_ms = None
cancel_mute_until_ms = 0
last_debug_print_ms = 0


def reset_to_idle():
    global state, presence_since_ms, last_motion_ms, prealarm_since_ms
    state = STATE_IDLE
    presence_since_ms = None
    last_motion_ms = None
    prealarm_since_ms = None
    led.value(0)
    all_alarm_off()


while True:
    now = time.ticks_ms()

    # 取消按钮优先级最高
    if is_button_pressed():
        cancel_mute_until_ms = time.ticks_add(now, CANCEL_MUTE_SECONDS * 1000)
        state = STATE_CANCELLED
        prealarm_since_ms = None
        last_motion_ms = now
        all_alarm_off()
        led.value(1)
        print("Alarm cancelled. Muted for %d seconds." % CANCEL_MUTE_SECONDS)

        # 等待松手,避免重复触发
        while cancel_btn.value() == 0:
            time.sleep_ms(20)

    info = radar.read_notification(timeout_ms=150)

    if info and info.get("type") == "calibration":
        print("Calibration countdown:", info["calibration_countdown_s"], "s")

    if info and info.get("type") == "result":
        target = info["target"]
        target_name = info["target_name"]
        valid = in_valid_zone(info)

        # 调试输出,每 2s 打印一次
        if time.ticks_diff(now, last_debug_print_ms) > 2000:
            last_debug_print_ms = now
            print(
                "state=%s target=%s valid=%s light=%.1f "
                "p_dist=%.2fm p_energy=%d m_dist=%.2fm m_energy=%d speed=%.2fm/s dir=%s"
                % (
                    state,
                    target_name,
                    valid,
                    info["light_lux"],
                    info["presence_distance_m"],
                    info["presence_energy"],
                    info["motion_distance_m"],
                    info["motion_energy"],
                    info["motion_speed_mps"],
                    info["motion_direction_name"],
                )
            )

        if target == C4002.NO_TARGET or not valid:
            reset_to_idle()

        elif target == C4002.MOTION:
            # 检测到明显运动:说明老人仍可活动,重置静止计时。
            presence_since_ms = now if presence_since_ms is None else presence_since_ms
            last_motion_ms = now
            prealarm_since_ms = None
            state = STATE_ACTIVE
            led.value(1)
            all_alarm_off()

        elif target == C4002.PRESENCE:
            # 检测到静止/微动人体
            if presence_since_ms is None:
                presence_since_ms = now

            if last_motion_ms is None:
                # 第一次进入即静止,按进入时间开始计时
                last_motion_ms = presence_since_ms

            led.value(1)

            in_cancel_window = time.ticks_diff(cancel_mute_until_ms, now) > 0
            static_seconds = time.ticks_diff(now, last_motion_ms) // 1000

            if in_cancel_window:
                state = STATE_CANCELLED
                all_alarm_off()

            elif static_seconds >= STATIC_ALARM_SECONDS:
                if state not in (STATE_PREALARM, STATE_ALARM):
                    state = STATE_PREALARM
                    prealarm_since_ms = now
                    print("PREALARM: static presence too long.")

            else:
                state = STATE_STATIC_WATCH
                all_alarm_off()

    # 根据状态驱动报警器
    now = time.ticks_ms()

    if state == STATE_PREALARM:
        beep_prealarm(now)
        relay_off()

        if prealarm_since_ms is not None:
            prealarm_elapsed = time.ticks_diff(now, prealarm_since_ms) // 1000
            if prealarm_elapsed >= PREALARM_SECONDS:
                state = STATE_ALARM
                print("ALARM: no cancellation during prealarm.")

    elif state == STATE_ALARM:
        beep_alarm(now)
        relay_on()
        led.value(1)

    elif state in (STATE_IDLE, STATE_ACTIVE, STATE_STATIC_WATCH, STATE_CANCELLED):
        # ACTIVE / STATIC_WATCH LED 表示有人,蜂鸣器和继电器关闭
        if state == STATE_IDLE:
            led.value(0)
        all_alarm_off()

    time.sleep_ms(20)
十一、代码逻辑说明11.1 UART 协议解析
C4002 的 UART 数据帧以 FA F5 AA A5 作为帧头。ESP32 先搜索帧头,再读取长度、帧类型、命令、响应码、数据段和校验和。
本项目代码实现了:
· _read_frame():从 UART 中读取并校验完整帧;
· _send_frame():按照 C4002 协议封装配置命令;
· _command():发送写/读命令并等待响应;
· read_notification():解析传感器主动上报结果;
· set_report_period():设置上报周期;
· set_resolution_mode():设置 80cm 或 20cm 距离分辨率;
· set_detect_range():设置最近/最远检测距离;
· configure_gate():启用/关闭距离门;
· set_target_disappear_delay():设置目标消失延时;
· set_light_threshold():设置光照阈值。
11.2 状态机逻辑
运行时主循环持续读取 C4002 通知帧:
· NO_TARGET:无人,关闭蜂鸣器和继电器。
· MOTION:有人且在运动,更新 last_motion_ms。
· PRESENCE:有人但只有静止/微动,开始计算距离上次运动的时间。
· 超过 STATIC_ALARM_SECONDS:进入预报警。
· 预报警超过 PREALARM_SECONDS 且无人按键:进入正式报警。
· 按下取消按钮:进入静默窗口,避免正常久坐反复报警。
十二、关键参数设置建议
参数
当前值
作用
调试建议
VALID_MIN_DISTANCE_M
0.40m
屏蔽过近噪声
小卫生间可设 0.30–0.50
VALID_MAX_DISTANCE_M
2.40m
屏蔽门外/墙外目标
根据卫生间实际长度调整
STATIC_ALARM_SECONDS
480s
静止多久预警
演示可设 30s,真实建议 6–12min
PREALARM_SECONDS
30s
预警确认时间
老人反应慢可设 60s
CANCEL_MUTE_SECONDS
600s
取消后静默
适合正常久坐场景
REPORT_PERIOD
10
1s 上报一次
不建议太快,避免串口数据过密
MIN_PRESENCE_ENERGY
5
静止目标能量下限
误报高则提高,漏报则降低
RESOLUTION_20CM
启用
小空间精细分区
大空间可用 80cm
十三、项目解决难点难点一:静止人体容易被传统 PIR 漏检
PIR 依赖人体热源运动变化,老人跌倒后如果长时间不动,PIR 可能无法持续输出“有人”。C4002 可检测静止/微动人体,更适合“人在但不动”的场景。本项目正是利用 PRESENCE 状态作为长时间静止判断依据。
难点二:卫生间隐私场景不能使用摄像头
跌倒检测常见方案会用摄像头或姿态识别,但卫生间不适合采集图像。本项目只使用毫米波状态、距离、能量等非图像信息,在隐私保护方面更合理。
难点三:卫生间空间小,门外人员可能误触发
毫米波具有一定穿透能力,且 120° 视场较宽,如果不限制距离,门外走廊人员可能被识别。本项目通过三层过滤降低误报:
1. set_detect_range(40, 240) 限定检测距离;
2. in_valid_zone() 对 presence/motion 距离再次过滤;
3. 目标能量低于阈值时不进入报警逻辑。
难点四:浴帘、换气扇、门摆动等动态干扰
卫生间内可能存在浴帘晃动、换气扇震动、门缝气流等干扰。处理方法:
· 安装时不要让雷达正对浴帘或风扇;
· 首次安装时进行环境底噪采集;
· 若某些距离门长期误报,可将对应 gate 关闭;
· 对目标能量设置下限;
· 报警逻辑不因瞬时 presence 触发,而要求持续静止超过阈值。
难点五:长时间静止不一定等于跌倒
老人正常如厕、洗澡休息也可能长时间静止。因此本项目不直接声称“跌倒识别”,而是设计为“长时间静止异常报警”。同时加入预报警和取消按钮:
· 先短促蜂鸣提醒;
· 老人可按键取消;
· 无人取消才进入正式报警。
这比直接报警更符合真实家庭使用。
难点六:MicroPython 下没有直接可用 Arduino
DFRobot 官方库主要面向 Arduino 和 Raspberry Pi Python。ESP32 MicroPython 无法直接调用 Arduino C++ 库,因此本项目手动实现 UART 帧封装、校验和通知解析。代码中完整实现了 C4002 命令发送、响应校验和目标数据解析。
十四、测试方案14.1 单人进入测试
步骤:
1. 老人或测试者进入卫生间;
2. 雷达应输出 MOTION;
3. LED 点亮;
4. 蜂鸣器与继电器不动作。
预期结果:系统进入 ACTIVE 状态。
14.2 静坐测试
步骤:
1. 测试者坐在马桶或凳子上保持静止;
2. 串口输出应逐渐保持 PRESENCE_STATIC;
3. 等待超过 STATIC_ALARM_SECONDS;
4. 系统进入 PREALARM。
预期结果:蜂鸣器慢速间歇响,继电器不动作。
14.3 取消按钮测试
步骤:
1. 进入预报警后按下取消按钮;
2. 系统停止蜂鸣;
3. 10 分钟内不再重复报警。
预期结果:系统进入 CANCELLED,避免正常如厕误扰。
14.4 正式报警测试
步骤:
1. 将演示参数 STATIC_ALARM_SECONDS 改为 30;
2. 静止 30 秒进入预报警;
3. 30 秒内不按取消按钮。
预期结果:系统进入 ALARM,蜂鸣器快速鸣叫,继电器吸合。
14.5 门外干扰测试
步骤:
1. 卫生间无人;
2. 让测试者在门外走动;
3. 观察串口距离和能量;
4. 调整 VALID_MAX_DISTANCE_M 或距离门。
预期结果:门外人员不触发 PREALARM 或 ALARM。
十五、改进方向
1. 联网通知:增加 Wi-Fi 与 MQTT,将报警发送到 Home Assistant、微信机器人或家庭网关。
2. 双传感器交叉验证:在较复杂卫生间中增加门磁或第二个 C4002,区分门外与室内。
3. 夜间灯光联动:检测到夜间有人进入时自动打开低亮度灯带。
4. 分区判断:根据距离门将“马桶区、淋浴区、门口区”分开,针对不同区域设置不同静止阈值。
5. 低功耗与掉电恢复:增加看门狗、掉电记录、报警日志存储。
6. 更柔和的人机交互:预报警阶段可先用语音提示:“请按按钮确认安全”
十六、结论
本项目基于 ESP32 MicroPython 与 DFRobot SEN0691 C4002 毫米波雷达,实现了卫生间长时间静止异常报警。方案避免了摄像头带来的隐私问题,也弥补了 PIR 传感器无法可靠检测静止人体的缺陷。通过距离范围限制、能量阈值、环境底噪采集、两级报警和取消按钮,系统具备真实家庭场景落地价值。
该项目不应被宣传为医学级跌倒诊断设备,而应定位为“隐私友好的长时间静止异常提醒系统”。在养老看护、独居老人安全、夜间卫生间监测等场景中,它能以低成本提供及时提醒,降低无人发现的风险。


您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

为本项目制作心愿单
购买心愿单
心愿单 编辑
[[wsData.name]]

硬件清单

  • [[d.name]]
btnicon
我也要做!
点击进入购买页面
上海智位机器人股份有限公司 沪ICP备09038501号-4 备案 沪公网安备31011502402448

© 2013-2026 Comsenz Inc. Powered by Discuz! X3.4 Licensed

mail