去年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按键。
抄板发现电路如下:

即有以下特点:
推测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秒时较为平衡,既不聒噪又能保持舒适。下一步可以在遥控器中装上温湿度计,实现闭环控制。