吉利远程V7E空调改远程控制
分类: 硬件 发布于: 2026-05-10

去年9万块买了一个吉利远程V7E面包车,内部空间巨大,打算做一张床在里面去露营郊游。但是空调是个硬伤,在车子后排没法够到中控台的空调按键,而且这车的空调不能调温,开了加热就热的头痛,开了制冷就冷得哆嗦。这勾起了我的改装欲,决定外加一个模块,实现空调的远程 + 温控。

一、总体设计

现代电动汽车有一个ECU或者VCU即整车控制器,空调按键最终连接到这里,这个控制器负责控制空调压缩机、风机和PTC等部件。所以想法是,在按键和VCU中间加一个模块,截获按键的输入,并自己伪装成按键上报到VCU。只要能完成这一步,借助WiFi模块就可以实现无线控制,并且可以在模块中集成类似PWM的调温逻辑。当然,这里受制于VCU和继电器的响应速度,肯定不能把PWM的频率设置到几khz这么高,而是一个非常低的水平,比如1分钟的周期内,加热30秒、关闭加热30秒,就能得到一个温和的温度。

二、按键逆向

这辆车有两个按键面板,方向盘左下侧有一个,主要控制手刹、驾驶模式和汽车灯光等;中控台的中间有一个,用来控制空调,包括风量、温度、除霜、加热和压缩机。

拆解灯光面板发现,其中有几个接线端是12V供电和CAN_H、CAN_L引脚,一个可行的办法是逆向CAN协议,向VCU发送控制指令。但由于手头没有CAN分析仪或者开发板,而且逆向原厂协议较为复杂,暂不考虑此方案。

拆解空调面板发现,只有一些阻容按键、耳机管之类的元件,没有芯片。这样就特别适合MCU读取和模拟了。其连接器是一个20pin接口,但有6个空针,即有效数据线为14pin。而这个面板有11个按键和5个指示灯,显然这些按键要么是矩阵按键,要么是AD按键。

抄板发现电路如下:

即有以下特点:

  1. (2P)有一个引脚直连电瓶的负极,另有一个引脚十分接近蓄电池电压,可以确定是12V供电引脚
  2. (2P)双闪和加热按键的一端连接在这个地线上,另外一端上报到主机(未必是VCU,通过线束传到中控台某盒子)或PTC控制器
  3. (6P)普通按键的背光灯占用一个引脚,双闪按键的背光灯占用一个引脚,4个指示灯占用4条线,不过这4个灯光在熄灭时为低电平,点亮时为高电平(12V)。它们和1中的12V供电形成回路
  4. (4P)P14引脚与地线存在零点几V的压差,P15、P16、P17与P14的相对压差为5.045V,依次按下9个按键时,P15~P17三个引脚相对P14的电压值会轮流发生变化。可以推测9个按键分布在这四个引脚中,构成一个AD按键网络。

推测P14是VCU中从电瓶供电中隔离出来的模拟地AGND,P15~P17分别通过一个一个电阻连接到VCU的ADCin引脚上。即以P15为例:

主机VCU侧:
            +5.045V
                 |
             R_pullup
                 |
                 +--------->  ADC输入
                 | 
                P15
                 | 
             [按键网络]
                 |
                P14 AGND

按键PCB侧:
按键闭合时,通过电阻连接到P14(地),形成分压,主机ADC读取电压

通过分别测量不同按键按下时P15~P17的电压,可以计算得出R_pullup的值约为750欧。这个值有参考意义,在设计的模块的AD输入端电路时,可以考虑同样串接此上拉电阻,这样可以参照原按键的AD值的比例来设计按键读取的逻辑。

三、电路设计

控制模块中模拟AD按键的电路:

其中,GPIO连接到TPL7407上,驱动电阻,模拟AD按键。TPL7407可以视作自带栅极下拉的NMOS管,当MCU的IO输出高电平时,对应路径上的电阻被下拉到地(P14),构成回路。

需要注意的是,不能直接将电瓶的负极短接到P14,这两路地可能是隔离的,直接短接轻则引入干扰,重则烧毁VCU。这里可以采用一个隔离的DC-DC模块解决供电的问题,从12V取电,降压到9V,再使用TPS54302降压到3.3V用作模块的电源。出于控制的方便,这里使用树莓派pico 2芯片rp2350作为主控,配合radio module 2模块实现远程通信(隔离DC-DC模块在图中未画出):

成品如下,可以完美塞入到中控台下的USB充电盒中:

四、软件

由于rp2350有两个核,不借助RTOS的情况下,可以用一个核心处理网络通信,另一个核心负责按键的读取和上报。这里借助一个消息队列,将需要执行的事务放在队列中,由消费端获取执行,这样可以确保串行化逻辑,避免状态机的混乱:

// core0 用于处理网络,忽略

void core1_main(void) {
    int8_t state = 0;
    uint32_t msg = 0;

    simple_queue_init();
    init_gpio();
    init_adc();
    aircon_init();

    // 读取ADC,检测按键
    init_timer_interrupt(2);

    while (1) {
        if (isr_timeout_count > 0) {
            while (1) {
                printf("error happened! overflow!\n");
                sleep_ms(1000);
            }
        }
        // 上报按键
        process_key_report();

        // 读取车机状态
        read_car_state();
    }
}

其中,上报逻辑是一个简单的状态机:

void process_key_report() {
    static int8_t state = 0;  // 0: IDLE, 1: PRESSED, 2: RELEASING

    if (state == 0) {        // 空闲,从队列里取出任务执行上报
        result = simple_queue_get(&msg);
        if (result != 0) {
            // 未取到数据,尝试从core0 获取
            uint8_t tmp;
            result = recv_message(&tmp);
            if (result != MQ_SUCCESS) {
                // core0 也为空,直接返回
                return;
            }
            msg = (uint32_t)tmp;
        }

        // press key
        uint32_t gpio = get_gpio_by_key((KeyID)msg);
        if (gpio == 0) {
            return;
        }
        gpio_put(gpio, true);
        // update status
        update_sys_state((KeyID)msg);

        // 需要针对消息进行状态置位
        action_time = time_us_64();
        state = 1;
    } else if (state == 1) {
        // 前面已经按下按键,现在等待时间释放
        if ((time_us_64() - action_time) >= PRESS_TIME) {
            // 已经按下足够时间,现在释放
            gpio_put(get_gpio_by_key((KeyID)msg), false);
            action_time = time_us_64();
            state = 2;
        }
    } else if (state == 2) {
        // 已经释放,等待一定时间再重置状态
        if ((time_us_64() - action_time) >= RELEASE_TIME) {
            state = 0;
        }
    }
}

按键检测的逻辑被注册到2ms的定时器中断服务中:

// 每2ms调用的按键检测任务
void ad_key_task_2ms(uint16_t adc_data[3]) {
    for (uint8_t ch = 0; ch < ADC_CH_COUNT; ch++) {
        // 1. 读取原始ADC值
        int16_t rawValue = adc_data[ch];
        
        // 2. 应用数字滤波
        int16_t filteredValue = applyFilter(&channelCtx[ch], rawValue);
        channelCtx[ch].filteredValue = filteredValue;

        // 3. 检测按键
        KeyID detectedKey = detectKeyInChannel((AdcChannel)ch, filteredValue);

        // 4. 更新状态机
        updateKeyState((AdcChannel)ch, detectedKey);
    }
}

需要注意的是,状态机需要考虑按键在按下或抬起过程中,AD的值可能穿越其它按键的区间的问题:

// 按键状态定义
typedef enum {
    KEY_STATE_IDLE = 0,      // 空闲状态
    KEY_STATE_DETECTING,     // 检测中
    KEY_STATE_PRESSED,       // 确认按下
    KEY_STATE_RELEASING      // 释放中
} KeyState;


// 配置参数
#define IDLE_THRESHOLD       3800    // 空闲阈值
#define DEBOUNCE_COUNT       4       // 去抖次数2ms * 3 = 6ms
#define FILTER_WINDOW_SIZE   5       // 滤波窗口大小
#define RELEASE_COUNT        3       // 释放确认次数


// 处理空闲状态
static void handleIdleState(ChannelContext* ctx, KeyID detectedKey) {
    if (detectedKey != KEY_NONE) {
        // 检测到可能的按键按下
        ctx->lastKey = detectedKey;
        ctx->state = KEY_STATE_DETECTING;
        ctx->debounceCount = 1;
    }
    // 保持在空闲状态
}

// 处理检测中状态
static void handleDetectingState(ChannelContext* ctx, KeyID detectedKey) {
    if (detectedKey == ctx->lastKey) {
        // 仍然是同一个按键
        ctx->debounceCount++;
        
        if (ctx->debounceCount >= DEBOUNCE_COUNT) {
            // 去抖完成确认按键按下
            ctx->state = KEY_STATE_PRESSED;
            ctx->debounceCount = 0;
        }
    } else {
        // 按键变化重新开始检测
        if (detectedKey == KEY_NONE) {
            ctx->state = KEY_STATE_IDLE;
        } else {
            ctx->lastKey = detectedKey;
            ctx->debounceCount = 1;
        }
    }
}

// 处理已按下状态
static void handlePressedState(ChannelContext* ctx, KeyID detectedKey) {
    if (detectedKey != ctx->lastKey) {
        // 按键变化
        if (detectedKey == KEY_NONE) {
            // 可能开始释放
            ctx->state = KEY_STATE_RELEASING;
            ctx->debounceCount = 1;
        }
    }
    // 保持按下状态
}

// 处理释放中状态
static void handleReleasingState(ChannelContext* ctx, KeyID detectedKey) {
    if (detectedKey == KEY_NONE) {
        // 仍然处于空闲状态
        ctx->debounceCount++;
        
        if (ctx->debounceCount >= RELEASE_COUNT) {
            // 确认释放发送按键消息
            sendKeyMessage(ctx->lastKey);
            
            // 重置状态
            ctx->state = KEY_STATE_IDLE;
            ctx->lastKey = KEY_NONE;
            ctx->debounceCount = 0;
        }
    } else {
        // 又检测到按键可能是抖动也可能是经历到另一个按键的区间
        // 一律等待释放归零释放计数器
        // !!! 关键点
        ctx->debounceCount = 1;
    }
}

static void updateKeyState(AdcChannel ch, KeyID detectedKey) {
    ChannelContext* ctx = &channelCtx[ch];
    switch (ctx->state) {
        case KEY_STATE_IDLE:
            handleIdleState(ctx, detectedKey);
            break;
        case KEY_STATE_DETECTING:
            handleDetectingState(ctx, detectedKey);
            break;
        case KEY_STATE_PRESSED:
            handlePressedState(ctx, detectedKey);
            break;
        case KEY_STATE_RELEASING:
            handleReleasingState(ctx, detectedKey);
            break;
    }
}
...

五、遥控器

遥控器的消息发生逻辑,取决于上述模块的server协议,这里是每个按键对应一个操作码,发送到模块之后会返回4字节的空调状态,定义如下:

// 空调状态联合体
typedef union {
    volatile uint32_t raw;  // 原始32位值
    
    struct {
        uint32_t power      : 1;  // 电源开关
        uint32_t circulation: 1;  // 内外循环
        uint32_t defrost    : 1;  // 除霜开关
        uint32_t ac         : 1;  // AC开关
        uint32_t heating    : 1;  // 加热开关
        uint32_t fan_speed  : 3;  // 风速档位(0-6表示1-7档)
        uint32_t temperature: 7;  // 温度编码(0-30对应17.0-32.0℃) (lo = 17 (temperature 0), hi = 32 (temperature 30))
        uint32_t mode       : 3;  // 五个mode
        uint32_t reserved   : 14; // 保留位
    } bits;
} AirConditioner;

配合简单的GUI如下:

实测效果十分完美,把调整周期设置在30~60秒时较为平衡,既不聒噪又能保持舒适。下一步可以在遥控器中装上温湿度计,实现闭环控制。