本文假装修理电脑开机键,其实是在解决无线电遥控研究的一个基础问题:怎样用廉价的方案持续监听ASK/OOK信号并自动解码。ASK/OOK广泛应用于各种无线电遥控设备,包括家电、卷帘门、车门……
背景
我的台式机的开机键确实是坏了。曾经买过一个尝试替换,但因规格不同装不上去;还用复位键代替开机键用过一阵子,但复位键太小,按得不爽。本来有小米的WiFi开关,但还不知如何整合。万般无奈,用无线电遥控按钮加上网络唤醒成了最后简便可行的方案。
另一方面,以前文章说过用软件无线电(比如HackRF + Inspectrum)可以接收ASK/OOK信号并解码,但有几个缺点:
- 成本高;
- 不能自动解码:如果要解码大量数据(比如研究滚码),用Inspectrum手动解码是非常烦琐的;
- 不能用于长期监听:因为GNU Radio储存的是高采样率的原始信号,每秒数MB。
所以,还是要再找一条路。
所需材料
- ASK接收/解调模块:图左上, 有两种,都是5元左右;
- 树莓派:图右上;
- 无线按钮:图下, 本来可以用其它无线遥控器代替,但这种比较像按钮。假货宝上不多见,略贵,15元。
ASK接收/解调模块的作用是将315/433MHz的调幅信号解调。除了电源,它有一个数据脚,输出解调的高低电平;有的模块有一个使能脚,但不接也行(因为浮动为高电平,即启用状态)。
图中有一个模块没接天线,其实也可以收到信号;也可以用一根20cm左右的导线做天线。感觉差别不大。
无线按钮的作用则相反:将一组数据编码,然后用315/433MHz调幅发射出去。这组数据是芯片预置的,据说每个芯片都不一样。编码方式实测是脉宽编码(PWM),这是无线电遥控器里最常用的。
认识接收模块
把这两个接收模块加上5V电源,然后用示波器观察数据引脚。
然后发现杯具了,怎么总是有“信号”?——这,就是传说中的噪声。便宜无好货? 也许。但电路噪声、空中拥挤的无线电、甚至树莓派产生的射频……要完全消除噪声,并不是那么容易的。所以,保持平常心。
再看有信号时:按一下无线按钮。这次倒比较像样。
为了验证其正确性,用树莓派发射一个信号。黄色是发射的,绿色是接收的。可以看到波形基本一致,有一丢丢的延时。
认识无线按钮
从图4可以看出,无线按钮的信号是脉宽调制(PWM)的,即一对高低电平的组合表示1bit,高电平较宽的代表1,低电平较宽的代表0。图中的波形可以解读为 1000 1010 1001 10...
缩小比例,看看整体。
可以看到,以一段大约10ms的低电平作为分隔,我们可以将其作为信号开始的标志,即 start0 + data (+ start0)。
在上一篇文章中,我们建立的遥控信号的模型是:先有一段较长的高电平作为开始,然后一小段低电平,然后是数据,最后有一段低电平作为结束,即 start1 + start0 + data + stop0。
显然后者是更合理的。因为没有信号时,接收模块的输出就是低电平。这个无线按钮有偷工之嫌。但我们能适应,硬件不够,软件来补。
关键代码
初始化
接收模块的驱动能力一般很弱,所以,不要使用上拉或下拉电阻。
# do NOT use pull_up_down=GPIO.PUD_UP nor GPIO.PUD_DOWN
GPIO.setup(pin_rx, GPIO.IN)
如何采样
树莓派有提供 callback(即 add_event_detect) 和 wait_for_edge,用于在每个电平跳变时进行响应/处理。但实际试了下,效果并不好。感觉它们更适合于低速的场合,比如检测传统的开关状态。
最后,回归到最朴素的轮询式。另一方面也是因为电路中有噪声,即使无信号时,数据脚也一直存在电平的跳变,用callback并不会更快。
实测采样周期设置为 0.1ms 或 0.05ms 都处理得过来。由于ASK/OOK数据频率通常在1~2KHz,所以 0.05ms相当于20KHz的采样率,足够了。
下面的代码保证间隔一个采样周期进行一次采样,返回采样时的电平及当时的时间。
def get_sample(self):
self.sample_time += self.sample_period
now = time.time()
wait = self.sample_time - now
if wait > 0:
time.sleep(wait)
b = GPIO.input(self.pin_rx)
now = time.time()
return (b, now)
接收一段信号
根据前面的分析,有的信号会以高电平作为前导,有的会以低电平作为前导。所以,下面的代码等待一段足够宽的电平(比如3~10ms)作为信号的开始,而不管它是高电平还是低电平。之所以忽略太长的电平,是因为没有信号(万一也没有噪声)时,将会一直是低电平。
我们记录起始时的电平,然后将每个电平翻转时对应的时间记入一个时间戳数组。我们忽略太窄的脉宽,因为它通常是毛刺(也意味着这多半是一段噪声);此时,我们直接丢弃这段“信号”。
直到下一段较宽的电平时,我们认为这段信号结束。将时间戳数组和起始电平作为一个波形对象返回。这相当于这个信号的物理层表示。
太短的“信号”也将被丢弃,因为它通常也是噪声;有意义的数据不会那么短。太长的“信号”也将被丢弃,因为这也可能是噪声,而且它会使数组过大,可能导致程序崩溃。
def receive(self):
wave = BitWave()
ts = wave.timestamp
b = GPIO.input(self.pin_rx)
now = time.time()
ch = GPIO.wait_for_edge(self.pin_rx, GPIO.BOTH, timeout=self.min_gap)
if ch is not None:
return None
b0 = b
t0 = now
ts.append(t0)
ch = GPIO.wait_for_edge(self.pin_rx, GPIO.BOTH, timeout=self.max_gap-self.min_gap)
b = GPIO.input(self.pin_rx)
now = time.time()
if ch is None:
return None
if b == b0:
return None
wave.startbit = b0
self.bit = b
self.sample_time = now
self.edge_time = now
ts.append(now)
min_gap = 1e-3 * self.min_gap
while True:
(b, now) = self.get_sample()
if b == self.bit:
if now - self.edge_time > min_gap:
if b == 0:
ts.append(now)
return wave if len(ts) > 5 else None
else:
if now - self.edge_time < self.sample_period:
return None
self.edge_time = now
self.bit = b
ts.append(now)
if len(ts) > self.max_len:
return None
return wave
PWM解码
解码是指,从物理层的波形,解码出数据链路层的比特流。
我们将接收到的波形按PWM来解码。但要注意,信号可能并不是PWM的,而且噪声可能带来毛刺或波形宽度的变化。所以,一旦碰到任何不符合预期的地方,我们毫不留情地返回失败。
首先我们要排除起始的高/低电平,最后一个bit也要排除,因为它通常带有结束时的低电平,而使得宽度不同于别的bit。用这一段数据,我们可以计算出每个符号(bit)的平均时长,作为周期。
在解码的过程中,任何一个电平宽度超过周期的,都被视为不合法。
同时,我们还把每对电平中较宽的脉宽累加起来,用其平均值/周期作为占空比。占空比太大也被视为不合法。因为对于1KHz的数据,如果占空比为95%,意味着窄的电平是0.05ms,这已经是我们采样精度的极限了。而且事实上,没人会用这么高的占空比,通常70%左右比较合理,这样误码率更低。
def decode(self, wave):
ts = wave.timestamp
size = len(ts) - 1
if size < 17: # 8 bits at least
return False
if wave.startbit == 1:
self.start1 = ts[1] - ts[0]
i1 = 2
else:
self.start1 = 0
i1 = 1
self.start0 = ts[i1] - ts[i1 - 1]
cbits = size - i1
if (cbits % 2) > 0:
return False
self.bits = BitArray()
csym = cbits / 2 - 1
dur = ts[-3] - ts[i1]
self.period = dur / csym
self.stop0 = ts[-1] - ts[-3] - self.period
if self.stop0 < 0:
return False
sum1 = 0
c1 = 0
for i in range(i1, size - 2):
w = ts[i + 1] - ts[i]
if w >= self.period:
return False
if w + w > self.period:
sum1 += w
c1 += 1
if (c1 == 0):
return False
self.duty = sum1 / c1 / self.period
if self.duty > 0.9:
return False
for i in range(i1, size - 1, 2):
w = ts[i + 1] - ts[i]
b = '0b1' if w + w > self.period else '0b0'
self.bits.append(b)
return True
效果
因为代码是轮询式,所以要关心一下CPU占用率。在树莓派3代上,采样周期设置为0.05ms时,用top查看,CPU占用率大约25%;而且改变采样周期,基本没有变化。换不同的电路板时倒有有差别(另一块电路板在10%以下,可惜已经挂了),可能和噪声水平有关系。
其实在同一个树莓派上,开两个进程,一个发送,一个接收,完全没有压力,收到的数据能正确解码。
至于噪声,虽然示波器上一直有噪声电平,但经过层层检验,几乎没出现把噪声解码为信号的情况。
但有信号时会出现误码,表现在:
- 如果发射端比较远或信号比较弱时,有一定的误码;
- 在按一下键时,通常会将信号重复发送3~5次,其中可能会有解码错误的;尤其是最后一个信号。其实最后一个信号可能根本没有完整发送。因为有的遥控器在按下键时会一直发送,松开时就立即停止发送。
- 经过博联(Broadlink)RM2这样的万能遥控器学习后、再发送的“二手码”,误码会更高一些。
下图是实际运行效果,简单说明下:
- [WAV] 表示原始波形信息。44.4ms/0/51 表示总长44.4ms,以低电平开始,总共51段,后面是每段电平的时长,单位ms。
- [PWM] 表示PWM解码成功,后面是其信息:起始高电平/低电平时长/结束低电平时长、比特周期、占空比、总位数、数据。
- 绿色框表示解码符合预期;橙色框表示虽然是正确的PWM码,但数据有丢失,不同于发射数据;红色框则是噪声了(不能成功解码)。
另外,对于按一下键,连续发射的多个信号,并不能全部接收到。因为接收到一个信号后,程序就会去解码,而解码耗费的时间可能会错过一个信号(的起始电平)。用多线程也许能解决这个问题。但并没有多大必要,因为发射端本来就会重复发送多次同样的信号,只要能接收到其中一个完整的就行了。
成果
有了这个自动接收/解码机,首先解码了无线电按钮,据此触发WOL,实现了按键就能开电脑的效果!(我容易吗我)
然后,很容易地解码了家里的所有无线电遥控器,做成字典。一方面,配合发射模块,可以程序控制所有设备;另一方面,可以监视家里的所有无线电活动(作为日志嘛)。当然,也许会收到别的信号(你懂的)。
结语
用树莓派+5元钱的ASK接收模块,即可实现对ASK/OOK信号的自动接收和解码。相比于软件无线电套件(HackRF, GNU Radio, Inspectrum),轻巧低廉。而且在这一领域,可以说更强大。实测用脚本语言就可以满足数据采集和解码的性能要求,开发调试非常方便。主要的问题是处理噪声,增加一些有效性检查。
在修好电脑开机键的同时,顺便完成了发射/接收,解码/编码的整套工具集,可以用于ASK/OOK的研究。
代码已开源:https://github.com/loblab/rfask
目前支持PWM和曼彻斯特编码。