纸上得来终觉浅,绝知此事要躬行. 关于 AI 的想法,最后如果不能真的在机器人身上验证,终究像一个空架子一般. 陈云所言,不唯上,不唯书,只唯实。说着确实好听,做起来却难。有时候会想,真的有人不只是喊口号,而是一直在按照这种原则行事吗? 对应到人工智能就是,人工智能不能只听人类的,也不能只看数据集,而是要从与现实的交互中学习. 这当然是六经注我了,却也能恰当地表明我所要采用的设计原则,制造机器人因此成了必经之路.

  还有两个原因:不论承认与否,人总是需要激励的,没有什么比看得见,摸得着的机器人更激励人心了;再者就是家中急需机器人帮忙分摊一部分家务,迫在眉睫! 预定要做的有:洗碗,晒衣,拖地,买菜,取快递和做饭. 做饭虽然复杂,也可以 step by step, 从泡面开始嘛. 而且机器人刀工肯定是比人类上限高的,我已经在期待着有一天它能够做出像松鼠鳜鱼这样的菜了. 通用人工智能什么的都可以暂且缓一缓,等到系列文章末尾,能做这些家务就心满意足了.

  理想很丰满,然而我的技能点似乎大部分点在了形而上的东西上,硬件方面除了组装过电脑主机,实操经验基本为零: 似乎我与机器人之间有种手搓原子弹的距离美. 这就需要把遥远的旅途分解成一个个小目标,当作游戏一样地去闯关,step by step 地前进了,刚开始的教学关总是不难的. 还没有卡关,就不要去畅想卡关的困难,一天的难处一天当就够了.

第一章 旅途的起点

初识 Arduino: 控制 LED 灯

主线任务: 第一次接触

你获得了新手启程大礼包,是时候研究一下它们能用来干什么了.

新装备 价格
Arduino MEGA2560 + 初学者套件 103.2 ¥

国产芯片,ATMEGA16U2主板,单板要六七十,套件里有 MEGA2560 的扩展板,面包板,跳线,和各种元器件(电容、电阻、LED、蜂鸣器等等).

纯粹的教学关,先试试 Arduino IDE 内置的 basic example, 让一个 LED 规律地点亮和熄灭,然后就可以多组几个 LED 玩些花样.

LED 脚或者内部金属片的一侧为正极.

组了 9 个LED,让长度为 4 的亮灯像贪食蛇一样地从左向右不停地移动.

int led_array[9] = { 10 , 9 , 8 , 7 , 6 , 5 , 4 , 3, 2 };
int len = 4, pos = 0;
int flash_speed = 100;

void setup() {
    Serial.begin(115200);
    for(int i = 0; i < 9; i++){
        pinMode(led_array[i],OUTPUT);
        digitalWrite(led_array[i],HIGH);    
    }
    for(int i = 0; i < len; i++){
    // 因为引脚接的 LED 负极,所以低电平输出反而是点亮
        digitalWrite(led_array[i],LOW);
        delay(flash_speed);
    }
}

void loop() {
    digitalWrite(led_array[pos],HIGH);
    digitalWrite(led_array[(pos+len)%9],LOW);
    pos = (pos+1)%9;
    delay(flash_speed);
}

用 Arduino 、蜂鸣器与按键开关做一个小型播放器

主线任务: 牛刀小试

看!这里有几个按键开关和一个蜂鸣器!也许可以用它们制造很厉害的装备!

这一关的小 Boss 有 5 个阶段:

  1. 用 Arduino 控制单个蜂鸣器发出单个频率的声音;
  2. 用 Arduino 控制蜂鸣器演奏由多个音符组成的一首简单乐曲;
  3. 用单个按键开关控制单个 LED 点亮或关闭;
  4. 用单个按键开关控制第 2 阶段中的乐曲播放或暂停;
  5. 最后是狂暴阶段,要将前面所有的技能集成在一起,做出3个按键分别表示上一曲,播放和下一曲,能播放多首内置乐曲的小型播放器.

一到三阶段都有详细的攻略,四阶段注意不能再使用 delay 函数了.
PROGMEM 能大幅减少运行所需的动态内存,对于一些新发售的 Arduino 型号,const 声明的变量会自动放入 PROGMEN 里,不过 MEGA2560 和 UNO 都要手动声明.

选了一些颇有年代感的儿歌,配上 Buzzer 的渣渣音质,一下子就有过去那种儿童玩具的味道了.

#define E3 165
#define G3 196
#define A3 220
#define B3 247
#define C4 262
#define D4 294
#define E4 330
#define F4 349
#define G4 392
#define A4 440
#define C5 523

#define BUZZER_PIN 8
#define BUTTON_NEXT_PIN 2
#define BUTTON_PLAY_PIN 3
#define BUTTON_PREV_PIN 4                                     

#define NOTE_DIV_INTERVAL 20
#define BUTTON_DIV_INTERVAL 50

unsigned long lastTime, curTime;

struct Button{
  int pin, lastButtonState;
  unsigned long lastButtonChangeTime;
  Button(int btn_pin){
    pin = btn_pin;
    lastButtonState = LOW;
    lastButtonChangeTime = 0;
  }
  int checkPressed(){
    if (curTime - lastButtonChangeTime > BUTTON_DIV_INTERVAL) {
      int buttonState = digitalRead(pin);
      if (buttonState != lastButtonState){
        lastButtonChangeTime = curTime;
        lastButtonState = buttonState;
        if (buttonState == HIGH) 
          return true;
      }
    }
    return 0;
  }
}btnPlay(BUTTON_PLAY_PIN), btnPrev(BUTTON_PREV_PIN), btnNext(BUTTON_NEXT_PIN);

const int PROGMEM music1[] = { // 兰花草
  A3, 2, E4, 2, E4, 2, E4, 2, E4, 6, D4, 2, C4, 3, D4, 1, C4, 2, B3, 2, A3, 8,
  A4, 2, A4, 2, A4, 2, A4, 2, A4, 6, G4, 2, E4, 2, G4, 2, G4, 2, F4, 2, E4, 8,
  E4, 2, A4, 2, A4, 2, G4, 2, E4, 6, D4, 2, C4, 3, D4, 1, C4, 2, B3, 2, A3, 4, E3, 4,
  C4, 2, C4, 2, C4, 2, B3, 2, A3, 6, E4, 2, D4, 3, C4, 1, B3, 2, G3, 2, A3, 8
};
const int PROGMEM music2[] = { // 两只老虎
  C4, 2, D4, 2, E4, 2, C4, 2, C4, 2, D4, 2, E4, 2, C4, 2, 
  E4, 2, F4, 2, G4, 4, E4, 2, F4, 2, G4, 4, 
  G4, 1, A4, 1, G4, 1, F4, 1, E4, 2, C4, 2, G4, 1, A4, 1, G4, 1, F4, 1, E4, 2, C4, 2, 
  C4, 2, G3, 2, C4, 4, C4, 2, G3, 2, C4, 4
};
const int PROGMEM music3[] = { // 世上只有妈妈好
  A4, 3, G4, 1, E4, 2, G4, 2, C5, 2, A4, 1, G4, 1, A4, 4,
  E4, 2, G4, 1, A4, 1, G4, 2, E4, 1, D4, 1, C4, 1, A3, 1, G4, 1, E4, 1, D4, 4,
  D4, 3, E4, 1, G4, 2, G4, 1, A4, 1, E4, 3, D4, 1, C4, 4,
  G4, 3, E4, 1, D4, 1, C4, 1, A3, 1, C4, 1, G3, 8
};
const int PROGMEM music4[] = { // Red River Valley
  G3, 1, C4, 1, E4, 2, E4, 1, D4, 1, C4, 2, D4, 1, C4, 1, A3, 2, C4, 6,
  E4, 2, C4, 1, E4, 1, G4, 2, F4, 1, E4, 1, D4, 6,
  G4, 1, F4, 1, E4, 2, E4, 1, D4, 1, C4, 2, D4, 1, E4, 1, G4, 2, F4, 4, 
  A3, 1, A3, 1, G3, 2, B3, 1, C4, 1, D4, 2, E4, 1, D4, 1, C4, 8
};
const int PROGMEM music5[] = { // Jingle Bell
  E4, 2, E4, 2, E4, 4, E4, 2, E4, 2, E4, 4, E4, 2, G4, 2, C4, 3, D4, 1, E4, 4, 0, 4,
  F4, 2, F4, 2, F4, 3, F4, 1, F4, 2, E4, 2, E4, 2, E4, 1, E4, 1, E4, 2, D4, 2, D4, 2, E4, 2, D4, 4, G4, 4, 
  E4, 2, E4, 2, E4, 4, E4, 2, E4, 2, E4, 4, E4, 2, G4, 2, C4, 3, D4, 1, E4, 4, 0, 4,
  F4, 2, F4, 2, F4, 3, F4, 1, F4, 2, E4, 2, E4, 2, E4, 1, E4, 1, G4, 2, G4, 2, F4, 2, D4, 2, C4, 6, 0, 2,
  G3, 2, E4, 2, D4, 2, C4, 2, G3, 6, G3, 1, G3, 1, G3, 2, E4, 2, D4, 2, C4, 2, A3, 6, 
  A3, 4, F4, 2, E4, 2, D4, 2, B3, 8, G4, 2, G4, 2, F4, 2, D4, 2, E4, 6, 0, 2,  
  G3, 2, E4, 2, D4, 2, C4, 2, G3, 6, G3, 1, G3, 1, G3, 2, E4, 2, D4, 2, C4, 2, A3, 6, A3, 2,
  A3, 2, F4, 2, E4, 2, D4, 2, G4, 2, G4, 2, G4, 2, G4, 2, A4, 2, G4, 2, F4, 2, D4, 2, C4, 4, G4, 4  
};

#define NOTE_STRIDE 2
#define MUSIC_LIST_LEN 5
int curMusicID, curFrequency;
int curNotePos, noteDurationLeft;
const int* curMusicPtr;

struct Music{
  const int* music;
  int size;
  int unitDuration;
  void init(){
    curMusicPtr = music;
    curNotePos = -NOTE_STRIDE;
    noteDurationLeft = 0;
  }
};
Music musicList[MUSIC_LIST_LEN]={
  music1, sizeof(music1)/sizeof(int), 150,
  music2, sizeof(music2)/sizeof(int), 200,
  music3, sizeof(music3)/sizeof(int), 300,
  music4, sizeof(music4)/sizeof(int), 300,
  music5, sizeof(music5)/sizeof(int), 130
};

bool isPaused;

void setup() {
  lastTime = millis();
  curMusicID = 0;
  musicList[curMusicID].init();
  isPaused = true;
  Serial.begin(115200);
}

void loop() {
  curTime = millis();
  if(btnPlay.checkPressed())
    isPaused = !isPaused;
  if(btnNext.checkPressed()){
    curMusicID = (curMusicID + 1) % MUSIC_LIST_LEN;
    musicList[curMusicID].init();
  }
  if(btnPrev.checkPressed()){
    curMusicID = (curMusicID + MUSIC_LIST_LEN - 1) % MUSIC_LIST_LEN;
    musicList[curMusicID].init();
  }
  if (!isPaused) {
    noteDurationLeft -= curTime - lastTime;
    if (noteDurationLeft <= 0) {
      curNotePos += NOTE_STRIDE;
      if (curNotePos >= musicList[curMusicID].size)
        curNotePos = 0;
      curFrequency = pgm_read_word_near(curMusicPtr + curNotePos);
      noteDurationLeft = musicList[curMusicID].unitDuration * pgm_read_word_near(curMusicPtr + curNotePos +1);

    }
    if (noteDurationLeft < NOTE_DIV_INTERVAL || curFrequency == 0)
      noTone(BUZZER_PIN);
    else
      tone(BUZZER_PIN, curFrequency);
  }
  else
   noTone(BUZZER_PIN);
  lastTime = curTime;
}

香橙派连接USB摄像头

主线任务: 一线光明(一)

它努力地尝试睁开眼睛,却是徒然.

Arduino 处理音视频方面也会比较吃力, 2560 也不带 Wifi 和 蓝牙, 因此需要一个中位机来处理这些.

在结构上,我尝试将机器人的控制分为上中下三层,

  • 下位机由 Arduino 或其他单片机负责,对标人类的脊髓,处理一些较为机械的,运动与感知相关的事宜;
  • 中位机由一个更完整的计算机系统构成,如装了 Ubuntu 的树莓派,负责处理浅层的音视频等任务,但不进行较为复杂的决策;
  • 上位机则是拥有足够的计算力的台式主机(甚至是云计算集群),负责综合所有信息,进行思考与决策.
新装备 价格
OrangePi Zero 3 (1G) + 扩展板 108.9 ¥

香橙派是树莓派的国产替代品. 这款 Zero 3 原本只有1个USB接口,接上扩展板能多一个音频口和两个USB口.

香橙派安装 Ubuntu

给香橙派安装系统,就像把大象装进冰箱一样,只有 3 步:

  1. 从香橙派官网下载要装的系统镜像: jammy-ubuntu22.04-server
  2. 用写入工具(用的 rufus)将系统写入 SD 卡;
  3. 将 SD 卡插入香橙派;

经历过安装黑群晖层出不穷的各种错误,有一种曾经沧海难为水的恍惚感?这就好了?
第一次使用需要接上显示器和USB键盘,用于连接 Wifi. 在路由器设置给对应 MAC 地址固定 IP,以后就可以只用 ssh 去连接.

使用 motion 实现局域网实时查看USB摄像头

motion 是一个可以监控摄像头视频信号并侦测运动的程序.

apt list motion

发现版本比 github 的旧一些,尝试直接从 github 安装.
查看系统信息:
echo $(lsb_release -cs) # jammy
echo $(dpkg --print-architecture) # arm64

在 github 找到对应 release 版本的链接.
wget https://github.com/Motion-Project/mtion/releases/download/release-4.6.0/jammy_motion_4.6.0-1_arm64.deb

使用 gdebi 安装能顺带处理好 dependency:
sudo apt-get install gdebi-core
sudo gdebi jammy_motion_4.6.0-1_arm64.deb

查看视频设备及其支持的输出(width, height, fps),发现有 video0-3 共 3 个设备,其中 video1 才是我所用的 usb 摄像头.
ls /dev/video*
v4l2-ctl -d /dev/video1 --list-formats-ext

根据设备输出,修改 motion 配置 sudo vim /etc/motion/motion.conf
video_device /dev/video1
width 640
height 480
framerate 30
picture_output off # 暂时不需要运动检测功能,关闭对应 output
movie_output off
stream_localhost off # 允许其他设备访问
stream_maxrate 30

开启服务:
sudo systemctl enable motion
sudo systemctl start motion

这时访问香橙派对应局域网ip,以及 motion 默认的 8081 端口就可以看到摄像头画面了.
http://192.168.2.100:8081/

香橙派 as 蓝牙耳机

主线任务: 心之声(一)

你会说话吗?没有应答…

使用 pulseaudio 播放音乐

接上耳机后,找个示例音乐,看看能否正常播放. 显示有两个声卡,用其中的 card 0 播放耳机才有声.

wget https://freetestdata.com/wp-content/uploads/2021/09/Free_Test_Data_1MB_WAV.wav -O test.wav
aplay -l
aplay test.wav -D hw:0

安装音乐播放工具 pulseaudio, 列表显示有许多模组(均衡器之类的),目前只需要其蓝牙模组.
sudo apt list pulseaudio*
sudo apt install pulseaudio pulseaudio-module-bluetooth
sudo usermod -G bluetooth -a orangepi

开启并连接手机蓝牙
bluetoothctl
power on
discoverable on
# 用手机蓝牙连接 orangepi,相互信任并配对后
paired-devices
# 连接手机对应的 MAC 地址
connect XX:XX:XX:XX:XX:XX 
exit

开启服务
pulseaudio --start

这时手机播放音乐耳机仍然没声,可能是 pulseaudio 默认的输出槽不对,改到 card 0 对应的 sink 即可.
pactl list sinks
pactl set-default-sink 0

还有一个问题遗留,orangepi 可以主动连手机,但手机端却无法主动连 pi, 总不能每次连蓝牙都要 ssh 进去输入 connect XXXXX 吧?尝试了许多操作,比如开启 pscan, sspmode, 重新配对,重启等等. 最后不知道是哪条起到了关键作用,因为不知道 pscan 或 sspmode 是不是默认打开的. 反正最终,手机确实可以像连蓝牙耳机一样连接 orangepi.

sudo hciconfig -a
# 开启  page and inquiry scan  
sudo hciconfig hci0 piscan 
# 开启 secure simple pairing mode, 避免总是使用PIN码配对?
sudo hciconfig hci0 sspmode 1

hciconfig -h 显示有许多命令,其中

  • pageto, 向其他设备发起连接请求.
  • pscan, 开启 page scan 模式, 这时可以响应其他设备的连接请求.
  • iscan, 开启 inquiry scan 模式,inquiry 指的是去发现其他设备,inquiry scan mode 则表示可以被其他设备发现的状态.
  • lm 连接模式,可选 CENTRAL 或 PERIPHERAL,中心设备还是边缘设备. 看到也有文档写叫 Master 与 Slave 即主从模式的,猜测可能是奴隶一词不好听,改名了.

充电电池:购买与测评

支线任务: 魔力之源

这就是灵石吗?是魔导水晶!

买了一些 18650 电池, 还没有用多久,都试过了再说.

初试焊接——没有箱的双声道音箱

主线任务: 心之声(二)

你刚刚是不是说了什么?对不起——声音太小,我没有听清.

之前在优信电子买了个 $3 \Omega$ 4 W$ 的喇叭准备将来给机器人用,当时什么也不懂,只知道大概要一个喇叭.
后来慢慢查资料才得知,啊?原来耳机孔的功率这么小的?还需要功放?

新装备 价格
3欧4瓦小喇叭*2 5.6 ¥
PAM8403 功放模块 0.7 ¥
PAM8403 功放模块 0.7 ¥


L298N 与 Arduino 小车

主线任务: 高速之星

你尽管开车,办法老爹来想!

按键开关控制小车轮子

PC手柄遥控小车

pip install pygame

使用 pygame.joystick 处理手柄输入,文档中的样例拿来直接就能用,现在只需要考虑如何将数据传输至 OrangePi.
因为将来可能要远程遥控机器人下楼买菜,最好别用距离有限的蓝牙. 综合考虑,改为手柄连接PC,PC 通过 socket 与 Pi 通信, 以后机器人下楼配个物联网卡和再加一个中转的服务器,除了延时变高外,同样还能用. 本来还要加一层,让 Pi 与下位机通信,但目前小车太小,放不下那么多东西,电机不多,Pi 的接口数量也够用,改为 Pi 直接连接 L298N.

Socket Programming HOWTO

L298N 目前 6 个 input 的作用

  • input1 右轮前进
  • input2 右轮后退
  • input3 左轮后退
  • input4 左轮前进
  • ENA 右轮电压
  • ENB 左轮电压

  • RT 表示前进,单 LT 表示后退,一起按住相当于不按(刹车),二者决定全局速度调控参数 $v_G \in [-1, 1]$

  • L1 为左摇杆表示左右方向的轴,用于转向,决定左右轮速度调控参数 $v_L, v_R$ 相对于 $v_G$ 的差.
    • $v_L = v_G \min{1+ 2L_1, 1}$
    • $v_R = v_G \min{1- 2L_1, 1}$

orangePi 开启 PWM 并安装 wringOP-Python

sudo orangepi-config

system/hardware 下:
勾选 ph-pwm12, pi-pwm1, pi-pwn1 保存并重启

sudo apt-get install swig python3-dev python3-setuptools
git clone --recursive https://github.com/orangepi-xunlong/wiringOP-Python -b next
cd ./wiringOP-Python/
git submodule update --init --remote
python generate-bindings.py > bindings.i
sudo python setup.py install