好久没写技术文章了,正好最近在开发一个基于香橙派Zero2的网络附加存储设备(俗称NAS),实际效果还不错,打算做一个完整的教程,给想要自己打造NAS的朋友们一些经验,如果文章中出现了错误或者遗漏,欢迎大家指出!

一、硬件参数

首先说一下机器的核心参数,这应该也是大部人最关心的地方。

1.价格预算

NAS整机价格210元左右(不包括硬盘和电源适配器):

  1. 一块香橙派Zero2主板(160元)
  2. 一张铠侠32Gb SD卡(25元)
  3. 一些硬件外设(25元)
    1. 12864 0.96寸 白色OLED显示屏模块(14元)
    2. 一堆散热片+杜邦线+红外接收头(10元)

2.核心硬件

主板是香橙派Zero2,CPU是ARM架构的全志 H616。没有使用x86,一是因为x86价格太高,最低都300起步。二是因为我有移动NAS的需求,过大的主机并不方便携带,而且12v供电也比较麻烦。

3.外设硬件

外设部分,除了香橙派Zero2主板和一块巨大的散热片以外,我额外增加了一个12864显示屏模块和一个红外模块,用来在SSH无法连接时紧急维护系统。

4.计算性能

H616的计算性能尚可,日常操作的过程中并没有遇到明显的计算性能瓶颈,4核CPU还是很给力的。而且这H616的发热相比Zero版本的H3好了太多太多,不加散热片的待机温度也就只有50出头,换做H3会直接上到六七十度,这是我感知最强的地方。因为并没有做太多计算密集的应用,CPU的计算性能不做过多的评价。

5.IO性能

IO性能方面主要是USB 2.0接口的传输速度作为参考,千兆以太网环境下,通过Samba向硬盘顺序写入时速度大约在25Mb/s。顺序读取时大约35Mb/s。带宽是所有USB接口共享的。以太网接口可能是独立的总线,并不会受到USB接口的影响。25Mb/35Mb/s的成绩和x86比起来并不算特别好,但也没有特别差,个人觉得在这个价位里面已经是比较优秀的水平了。

6.整机功耗

借助ARM架构的优势,NAS的总功耗特别低(相比x86),使用一个5v2a的手机充电器就可以轻松带起,当然用同样可以使用充电宝供电。

能耗设备 待机时功率 满载时功率
香橙派Zero2主板 1w 2.5w
2.5寸SATA转USB硬盘1 0.5w 2.5w(上电时峰值4w)
2.5寸SATA转USB硬盘2 0.5w 2.5w(上电时峰值4w)
OLED模块 + 红外模块 0.02w 0.06w

二、软件配置

我只安装了samba服务,用来给Windows机器做网络共享,平时会存储一些音视频文件到NAS里。大多数场景都是以顺序读取为主。

NAS有USB2.0接口,可以连接两块硬盘。一块是平时使用的主硬盘,一块是用作镜像备份的从硬盘。平时读取和写入都是对主硬盘在进行操作,从硬盘的作用是每天凌晨4点从主硬盘镜像文件,做备份用途,主要用来防止我误删文件。

OLED显示模块和红外方面,我开发了对应的程序,能把OLED模拟一个真正的Linux终端,可以运行shell程序,比如bash、sh、zsh,甚至可以在OLED上运行TUI程序(Terminal User Interface),比如vim编辑器等。此时遥控器作为输入设备,我把遥控器上的0-9数字键做成了一个简单的九宫格英文输入法。紧急时刻可以通过遥控器打字输入关机或者重启命令来操控机器。

演示图片:在OLED终端上运行Vim编辑器

in_vim_editor

OLED软件程序因为使用了wiringpi-python这个硬件抽象层(Hardware Abstraction Layer),移植到树莓派全系列上也是极其容易的,可惜树莓派太贵了。

三、硬件教程

整个NAS的硬件并不是很多,除去核心的主板硬盘外,只有一个OLED屏幕,和一个红外接收头。

all_hardwares

详细介绍一下这些硬件吧。

机械硬盘使用了SATA转TypeC硬盘盒,用USB线和香橙派进行连接,我这里的型号是斐讯的N1盒子,为了减小体积我只保留了那块PCB,没有外壳。

这里的OLED屏幕主要是当做一块应急显示器使用,配合红外接收头,可以在无法联网或者没法连接串口的情况下操作香橙派系统

香橙派的型号是Zero2,相比Zero型号,电源接口改成了TypeC,同时增加了一个MicroHDMI接口。内存增加到了1Gb,可以跑一些中型应用无压力。这里需要说明一下,MicroHDMI接口只能使用转接线,不能使用转接口。因为接口那个位置空间非常挤,TypeC电源线和旁边的USB接口会挡住MicroHDMI转接口。不过好在Zero2同样提供了一个串口,完全可以替代MicroHDMI接口,使用USB2TTL串口线来登录系统。Zero2还支持双频WiFi,BLE5.0和千兆以太网,我自己主要使用有线连接,就没有接WIFI天线。


一切准备好之后开始连接这些元件,我做了一根双头的USB TypeC数据线连接到香橙派开发板上。红外接收头和OLED屏幕也是同样使用杜邦线连接。

all_hardwares_with_cables

zero2_pins

硬盘的USB接口连接到开发板左侧的13pins接口的最下方6个接口上,也就是写着5VGNDUSB2USB3的引脚上。USB一共有4根线:VCC、GND、DM、DP。其中VCC和GND是供电线,DM和DP则是负责传输数据的线。上面2个USB接口需要8根线,却只有6个引脚是因为USB的两根供电线是共用的,并联的。因此需要我们自己做一个排针转USB的转接线(也就是我上图里那根线)

OLED屏幕是i2c协议的,需要连接到开发板右侧的26pins接口的左上方的:OUT、SDA、SCK、GND这4根线(中间跨过了一个PC9引脚)。I2C接口和USB接口类似,同样是2根电源线和2根数据线。具体线序如下:

OLED屏幕 Zero2开发板
VCC OUT
GND GND
SCL SCK
SDA SDA

连接好i2c接口后还需要在系统中开启i2c接口功能。具体做法是使用root用户编辑/boot/orangepiEnv.txt文件。在文件末尾新增一行overlays=i2c3然后保存关闭并重启系统。如果一切无误,可以在/dev目录下找到/dev/i2c-3这个设备文件。

连接好OLED模块和开启i2c接口后,可以使用sudo i2cdetect -y 3命令来扫描i2c-3总线下所有连接的的i2c设备。一切无误的话,可以看到OLED的i2c地址是0x3c

orangepi@zero2:~$ sudo i2cdetect -y 3
[sudo] password for orangepi:
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- 3c -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --
orangepi@zero2:~$

最后是红外模块的链接。红外模块的引脚很简单,2根电源线和一个数据输出线。数据输出线需要并且只能连接到开发板左侧的13pins接口的最上方的IR引脚上。2根电源线可以随便在开发板上找一个3.3v的VCC和GND连接(建议使用3.3v电压,不要使用5v)

红外模块的测试很简单,使用命令cat /dev/lirc0就可以开始监听红外模块的数据。此时用任何遥控器对着红外模块一通乱按,屏幕上会出现各种乱码数据,就代表了红外模块在正常工作。(之所以是乱码数据是因为这些数据是按一定二进制格式排列的,需要编写专门的解码程序才可以解码出对应的数据。如果即想查看具体的数据,又不想写代码,可以安装官方用户手册里推荐的ir-keytable这个软件来查看解码后的红外数据,这里我就不细说了)

一切安装好以后,就可以组装到机架上了(如果有),我就直接放到一个雪糕棍做的建议机架了啦,虽然不太美观,但是成本特别低。DIY起来也比较快。

on_rack

四、软件教程

NAS的主要功能是存储文件,所以这一章节主要讲解文件存储相关的内容。和OLED,红外模块有关的内容会放到后面的番外篇讲解。

之前我们已经安装完成了所有的硬件,现在需要开始搭建存储服务相关的软件了。大致原理是这样,使用fstab来启用硬盘的开机自动挂载,同时安装samba软件将硬盘共享给局域网上的Windows电脑,最后设置一个简单的shell脚本在每天凌晨4点自动镜像主硬盘的内容到从硬盘。

1.自动挂载硬盘

在使用fstab自动挂载之前,需要先确定每块硬盘的分区UUID,可以使用sudo blkid命令来查看,比如我的输出是这样的:

/dev/mmcblk1p1: UUID="234af595-2414-4288-afc1-7f7f04667fce" BLOCK_SIZE="4096" TYPE="ext4" PARTUUID="d5bda6f9-01"
/dev/sda1: LABEL="WD2017_HOT" BLOCK_SIZE="512" UUID="02D4FF22D4FF171F" TYPE="ntfs" PARTLABEL="Basic data partition" PARTUUID="009216f4-dc14-40e0-9a3e-41521d1d12af"
/dev/sdb1: LABEL="WD2013" BLOCK_SIZE="512" UUID="70B6F414B6F3D914" TYPE="ntfs" PARTUUID="05314b90-01"
/dev/zram0: UUID="7173152c-b754-4be1-b04e-ed71b6269180" TYPE="swap"
/dev/zram1: LABEL="log2ram" UUID="6b86757f-98aa-48fd-bc6f-a14549f51347" BLOCK_SIZE="4096" TYPE="ext4"
  • mmcblk1p1:Sd卡的设备文件
  • sda1:主硬盘的设备文件
  • sdb1:从硬盘的设备文件
  • zram0:内存压缩相关的文件(系统自带请忽略)
  • zram1:ram-log相关的设备文件(系统自带请忽略)

我们重点关注sda1和sdb1就好了,sda1和sdb1后面的1就是代表第一个分区,如果你的硬盘有多个分区,会分别出现sda2或者sda3这样的设备文件,我这里默认只分了一个分区。

这里记下sda1和sdb1的UUID、UUID代表了硬盘分区的唯一标识符,通过UUID可以定位到任何一个硬盘分区且不会重复和冲突。至于为什么不用卷标(LABEL),是因为卷标是允许2个分区重复的,而UUID没有这个问题。

确定了UUID之后,需要两个空目录来用作2块硬盘的挂载点。挂载点目录一般会被新建在/mnt目录下,我这里作为演示,就新建两个目录好了:/mnt/disk_moo/mnt/disk_forest,名字可以随意。

最后一步编辑/etc/fstab文件(记得使用sudo否则无法覆盖保存),在文件的末尾新建两行

UUID=02D4FF22D4FF171F /mnt/disk_moo auto defaults,noatime,nofail 0 0
UUID=70B6F414B6F3D914 /mnt/disk_forest auto defaults,noatime,nofail 0 0

这里的UUID就是刚才记下的硬盘分区的UUID。UUID后面紧跟着一个挂载路径,意思是将硬盘挂载到那个目录上,挂载成功后就可以使用/mnt/disk_moo/mnt/disk_forest来访问硬盘里的文件了。后面的auto表示挂载时自动识别分区的文件系统。当然也可以手动指定,但这里我就直接使用auto了,比较省事。再后面的defaults,noatime,nofail是一个整体,被称作options(挂载选项,或者叫参数)defaults对应一些默认的参数,noatime表示不记录文件访问时间,可以提升性能。nofail则表示如果找不到硬盘或者挂载失败,就跳过此条规则,而不是直接使操作系统启动失败。最后面的两个0,前者表示备份选项,后者表示fsck的检查顺序,一般都为0

完成后效果如下,可以看到文件里本身已经自带了2条挂载规则,分别是根目录的/的挂载配置,和临时目录/tmp的挂载配置

UUID=234af595-2414-4288-afc1-7f7f04667fce / ext4 defaults,noatime,commit=600,errors=remount-ro 0 1
tmpfs /tmp tmpfs defaults,nosuid 0 0
UUID=02D4FF22D4FF171F /mnt/disk_moo auto defaults,noatime,nofail 0 0
UUID=70B6F414B6F3D914 /mnt/disk_forest auto defaults,noatime,nofail 0 0

2.安装samba服务

samba服务可以通过apt命令来安装,非常方便sudo apt install samba。安装好后编辑配置文件vi /etc/samba/smb.conf。samba的配置文件很简单,只需要新建两个共享目录就好了,在文件末尾添加这些代码即可:(关于smb.conf完整讲解可以参考samba的官方wiki(英文):https://wiki.samba.org/index.php/Main_Page)

#======================= Share Definitions =======================

[disk_moo]
  path = /mnt/disk_moo
  read only = no
  inherit permissions = yes

[disk_forest]
  path = /mnt/disk_forest
  read only = yes
  inherit permissions = yes

我这里新增了两个共享目录,一个叫disk_moo,一个叫disk_forest。也就是方括号里的名字,这个名字会显示在Windows的共享目录上,可以自己按喜好去修改,建议不要使用中文。

下面各个选项的作用如下:

  • path:共享哪个目录?
  • read only:共享目录是否开启只读模式
  • inherit permissions:共享目录下创建的新文件/目录是否沿用父目录的权限标志位

我这里主硬盘设置为了可读可写模式,从硬盘只设置为了只读模式,防止误删数据。要注意的是这里的只读模式仅对samba的网络访问有效,你自己在本地系统里使用命令行删改里面的文件仍然是不受影响的

3.设置samba账号

所谓samba账号就是从网络访问共享目录时需要输入的身份验证的账号和密码。samba账号有些奇怪,账号名是直接沿用的系统里的Linux用户名,但是密码却又要单独设置一个不一样的密码,总之就是很麻烦。

首先需要单独创建一个Linux User。然后把这个User添加到samba数据库里,最后为这个User设置Samba密码:

useradd -M -s /sbin/nologin demoUser
passwd demoUser
smbpasswd -a demoUser

最后是一些Samba相关的管理指令,方便参考:

添加 samba用户:

smbpasswd -a  <user>
pdbedit -a -u <user>

修改用户密码:

smbpasswd  <user>

删除用户和密码:

smbpasswd –x <user> 
pdbedit  –x –u <user>

查看samba用户列表:

pdbedit –L –v

查看samba服务器状态:

smbstatus

samba官方的安装教程可以参考这里:https://wiki.samba.org/index.php/Setting_up_Samba_as_a_Standalone_Server

4.rsync同步文件夹

最后为了数据的可靠性,我们设置一个简单的shell脚本,在每天凌晨4点自动镜像主硬盘的内容到从硬盘。

这里我们不再单独写程序了,而是直接使用Linux系统自带的一个特别方便的命令rsync。

rsync的原意是远程同步的意思,也就是它是支持ssh协议跨机器同步的,但我们这里只会用到本地同步的功能。

同步的命令很简单:sync -a -r --progress --delete --force /mnt/disk_moo/ /mnt/disk_forest

其中的选项:

  • -a:应用一些默认的同步参数,展开后为-rlptgoD
  • -r:递归所有目录,同步所有子目录和文件
  • --progress:同步时显示进度条
  • --force:同步时出现非空目录也会强制删除

因为我们的硬盘是外接的设备,有时候可能会出现硬盘连接断开的情况,我们在同步之前需要先判断主从硬盘是否都在线,如果有任意一个硬盘离线了,那么就取消这次同步。

具体的做法是在同步脚本里检查硬盘的UUID文件是否存在,如果不存在,那就说明硬盘断开了连接,就不能进行同步:

#!/bin/bash

disk_moo="/dev/disk/by-uuid/02D4FF22D4FF171F"
disk_forest="/dev/disk/by-uuid/70B6F414B6F3D914"

if [ -e $disk_moo ] && [ -e $disk_forest ] ; then
    echo syncing..
    sync -a -r --progress --delete --force /mnt/disk_moo/ /mnt/disk_forest
    echo done
fi

写好了同步脚本之后,建议运行一次,无误之后就可以加入到系统的定时任务里了,将在每天的固定时间执行。

定时任务这里使用crontab命令实现,使用crontab -e命令打开交互式编辑器,如果是第一次使用这个命令,会询问你要使用那个文本编辑器,记得选Nano编辑器,不要选vim,否则你连怎么退出vim都不知道。

进入编辑器后在文件末尾新增一行0 4 * * * /home/orangepi/.file-sync.sh.file-sync.sh文件的路径记得换成你自己的。这里有一些参数需要说明一下:0和4表示每当遇到4点0分时就执行后面的命令,而星号则是表示每个月中日,周中日,每个月份都会执行命令,这三个合起来就是每天的意思

0 4 * * * /home/orangepi/.file-sync.sh
| | | | |   |
| | | | |   |
| | | | |   +--  command(要执行的命令)
| | | | +-----   dow(周中日)
| | | +-------   mon(月份)
| | +---------   dom(月中日)
| +-----------   h(小时)
+-------------   m(分钟)

这样每天凌晨4点0分,系统就会自动执行文件同步脚本,镜像硬盘里的文件了。

到这里整个NAS的核心功能:提供存储服务。就是是完成了。

五、番外篇

番外篇会专门用来讲解OLED模块和红外模块的实现思路,因为文本量实在太大,就不一句一句地讲解代码了。本章节只会写出一些功能或者系统的核心思路。

把OLED模块做成Linux终端的想法最开始是来源于稚晖君的卡片电脑,毕竟我也喜欢给各种小物件接上屏幕。稚晖君的卡片电脑上的屏幕是一块彩色的SPI屏幕,而我手上只有一个12864的单色OLED。

SPI彩色屏幕受系统原生支持的,只需要接好线,打开设置开关,开发板的画面就能直接显示在屏幕上,无需敲任何代码,很是方便快捷。

但我的12864 OLED模块则不行,不仅无法显示彩色画面,连驱动IC,硬件I2C接口也没有系统的原生支持,不能直接使用。想要显示各种图像,就必须得自己绘制渲染,自己跟硬件通信,传输画面到显示器上,全程都要亲历而为。

但这个项目并不是特别困难,因为有社区上有各种前辈,大佬开发的现成的各种库可以使用,再加上我之前有给树莓派做过类似的项目,不仅有现成的经验,还有一部分通用的代码,开发难度一下子降低了不少

整个程序不是特别依赖CPU性能,考虑到开发效率,我选用Python语言来做开发。在操作硬件方便,官方提供了一个wiringpi的python绑定叫wiringpi-python。由于有这个硬件抽象层的存在,开发好的程序可以极为方便地移植到树莓派上。


1.软件结构图示

软件的整体结构图如下

image-20220706110333105

  • Display是整个APP的枢纽类,负责协调用户输出,图形绘制,数据传输工作。
  • 窗口之间的关系逻辑是基于栈的结构进行管理的。当一个新窗口被打开时,就会被加入到栈顶。当栈顶的窗口退出时,会被从栈顶移除。同时同一时间里有且只有栈顶窗口是活跃的,也避免了一些潜在的状态冲突问题。
  • 用户输入分两路,红外输入和键盘输入,键盘输入主要是为了调试使用,可以模拟红外输入的所有功能。所有的输入数据会经过Display类到达栈顶窗口
  • Transmitter类负责与OLED硬件通信,把绘制好的画面缓冲区里的数据传输到OLED屏幕上进行显示

这里有意思的是位于栈底的根窗口,它既是一个窗口,又不是一个窗口。说它是一个窗口,是因为它和一般窗口的API无异,可以把它当做一个窗口来看待。说它不是一个窗口,是因为它不产生任何画面数据,也不消费任何输入数据。它只是一个普通的转发器,将输入数据传递给上层的窗口1。同时将窗口1产生的画面数据传递给Transmitter。

如果窗口1被退出,那么根窗口也会一同退出。当根窗口退出的时候,Display就会退出,整个APP也会结束运行。根窗口的生命周期是和窗口1绑定在一起的,这样做高层开发时,就好像根窗口不存在一般,可以减少逻辑上的复杂度。

为了执行效率最大化,红外输入键盘输入DisplayTransmitter根窗口每一个人都在一个独立的线程环境中运行。

Display的线程是主线程,当程序启动之后,Display的init方法会被调用,做一些初始化的工作,比如启动其它4个守护线程。初始化工作做完以后,就会进入阻塞,等待根窗口的主循环线程(也叫绘制线程)和传输线程的退出,当绘制线程和传输线程都退出之后,主进程返回并退出,同时其它2个负责输入的守护线程也会一并被操作系统结束,进程到这里也会随之退出。

5个线程里最重要的就是:绘制线程和传输线程,一个负责绘制画面内容,绘制好后交给传输线程去传输到OLED硬件,传输的同时又可以接着画下一帧内容。而不必等待整个传输完成后才能画下一帧内容,效率提高了不少。

2.红外信号的处理

Zero2原生支持接受红外信号,甚至驱动程序都已经安装好了,可以直接开箱即用。更多资料可以参考Zero2的官方用户手册(中文)

硬件方面,也就是红外模块,一般只有3个引脚:2根电源引脚和一根数据输出引脚。数据输出引脚很简单,当红外模块接受到红外线时,输出高电平,没有信号时输出低电平。

如果你感兴趣的话,可以把遥控器的红外发射管对着手机的摄像头按下任意按键。会发现遥控器上的红外发射管会以非常快速度在闪烁。遥控器上的红外发射管每次亮起的时长和间隔,都是包含着特定的数据的。只不过太快了,人眼无法分别。但硬件电路,也就是我们的Zero2却可以精准地识别到每一个电平变化的时间间隔。

当红外发射管亮起的时候,红外接收模块输出高电平;当红外发射管熄灭的时候,红外接收模块随之输出高电平,也就是说两者是完全同步的。通过解析这些电平变化数据,我们就能解析出遥控器发射出的原始二进制数据了。

首先介绍一下红外编码的知识,红外信号就是遥控器的红外线LED发射的一连串二进制数据。红外信号有非常多的编码格式,目前使用最广泛的是NEC格式,比如电视机,DVD播放器,电灯,风扇等(空调除外)。所以本文就以NEC协议进行解码,如果你感兴趣也可以自行编写其它协议的解码程序

文章参考:https://blog.csdn.net/Liu_959185/article/details/86636514

NEC协议由2个变体组成:

  • 当正常按下按键时:
    • 引导码
    • 地址码(个人猜测可能是用来识别不同的遥控器的编号的)
    • 地址码反码
    • 指令码(个人猜测可能是用来识别按下遥控器上哪个按键的)
    • 指令码反码
  • 当按住同一个按键不放时:
    • 重复码

引导码结束后,遥控器会根据用户按下的状态,发送不同的数据。如果一个按键是第一次按下,则发送:地址码和指令码。如果是一直按住不放,就会每隔110ms发送一个重复码,直到用户松开按键为止

正常按下的电平图如下:

image-20220706115423482

引导码由一个9ms的高电平和一个4.5ms的低电平组成。结束后紧跟着的是地址码。

地址码是一个8位二进制数据,低位字节在前,是大端模式。

每一个逻辑位是这样区分的:无论是逻辑1还是逻辑0都是以一个560us的高电平开头。然后会紧跟着一个低电平,这个低电平的持续时间就决定了是逻辑1还是逻辑0。

如果低电平时长为1690us。则表示当前的逻辑位是1。如果低电平时长为560us。则表示当前的逻辑位是0

image-20220706121044449

当用户按下一个按键不放时,为了省电,除了第一次会发送一次地址码和指令码以外,后面会每隔110ms发送一个重复码。

按住不放时,重复码的电平图如下:

image-20220706121620052

重复码的数据比较简单。由一个9ms的高电平和一个2.25ms的低电平组成。每隔110ms发送一次。

image-20220706121615445


香橙派的红外接收采用的是中断模式,也就是每当红外模块的电平变化时,通知一下你。具体获取红外数据的方法也很简单,只需要不断地读取/dev/lirc0这个文件的内容就好了,这个文件是lirc向用户空间暴露的红外数据接口。我们直接读取这个文件就能获取到GPIO上的电平变化数据,而不不需要自己去初始化GPIO参数。

读取很简单,把这个文件当做一个普通文件来读取就好,每次读取4个字节。按低位字节在前的大端模式将这4个字节组合成一个32位无符号整型。低24位是距离上次电平变化以来的时间。第25位是当前的电平数据(变化后的)第26位-32位可以舍弃。

通过解码程序可以解码出4个字节的数据,分别是:地址码、地址码反码、指令码、指令码反码。其中反码是用于校验数据的,将反码取反,与前面的数据进行对比即可。若有任意一处不匹配,则本次接收的数据就是无效的。

3.给香橙派适配Adafruit_SSD1306库

Adafruit_SSD1306库是我之前给树莓派做OLED项目时使用的一个SSD1306显示IC的驱动库。官方仅适配了RPI和BBB。并木有适配香橙派,因此无法直接使用。这里需要我们手写一个GPIO适配器类来让Adafruit_SSD1306能顺利在香橙派上工作。

OrangePiZeroI2C.py:GPIO的I2C适配器

from Adafruit_GPIO.I2C import Device

class OrangePiZeroI2C:
    def get_default_bus():
        return 3

    def get_i2c_device(address, busnum=None, i2c_interface=None, **kwargs):
        """Return an I2C device for the specified address and on the specified bus.
        If busnum isn't specified, the default I2C bus for the platform will attempt
        to be detected.
        """
        if busnum is None:
            busnum = OrangePiZeroI2C.get_default_bus()
        return Device(address, busnum, i2c_interface, **kwargs)

OrangePiZeroGPIO.py:GPIO的通用操作适配器

from Adafruit_GPIO.GPIO import *
import wiringpi

class OrangePiZeroGPIO(BaseGPIO):
    """GPIO implementation for the Orange Pi Zero 2 using the wiringpi-Python library."""

    def __init__(self):

        # Define mapping of Adafruit GPIO library constants to RPi.GPIO constants.
        self._dir_mapping = { OUT:      wiringpi.GPIO.OUTPUT, 
                              IN:       wiringpi.GPIO.INPUT }
        self._pud_mapping = { PUD_OFF:  wiringpi.GPIO.PUD_OFF,
                              PUD_DOWN: wiringpi.GPIO.PUD_DOWN,
                              PUD_UP:   wiringpi.GPIO.PUD_UP }
        self._edge_mapping = { RISING:  wiringpi.GPIO.INT_EDGE_RISING,
                               FALLING: wiringpi.GPIO.INT_EDGE_FALLING,
                               BOTH:    wiringpi.GPIO.INT_EDGE_BOTH }

    def setup(self, pin, mode, pull_up_down=PUD_OFF):
        """Set the input or output mode for a specified pin.  Mode should be
        either OUTPUT or INPUT.
        """
        wiringpi.wiringPiSetup()
        self.rpi_gpio.setup(pin, self._dir_mapping[mode],
                            pull_up_down=self._pud_mapping[pull_up_down])

    def output(self, pin, value):
        """Set the specified pin the provided high/low value.  Value should be
        either HIGH/LOW or a boolean (true = high).
        """
        wiringpi.digitalWrite(pin, value)

    def input(self, pin):
        """Read the specified pin and return HIGH/true if the pin is pulled high,
        or LOW/false if pulled low.
        """
        return wiringpi.digitalRead(pin)

    def input_pins(self, pins):
        """Read multiple pins specified in the given list and return list of pin values
        GPIO.HIGH/True if the pin is pulled high, or GPIO.LOW/False if pulled low.
        """
        # maybe rpi has a mass read...  it would be more efficient to use it if it exists
        return [wiringpi.digitalRead(pin) for pin in pins]

    def add_event_detect(self, pin, edge, callback=None, bouncetime=-1):
        """Enable edge detection events for a particular GPIO channel.  Pin 
        should be type IN.  Edge must be RISING, FALLING or BOTH.  Callback is a
        function for the event.  Bouncetime is switch bounce timeout in ms for
        callback
        """

        # do nothing due to edge detection not work at all in OrangePiZero2 for some unknown reason
        # the callback will never be called

        # kwargs = {}
        # if callback:
        #     kwargs['callback']=callback
        # if bouncetime > 0:
        #     kwargs['bouncetime']=bouncetime
        # self.rpi_gpio.add_event_detect(pin, self._edge_mapping[edge], **kwargs)

    def remove_event_detect(self, pin):
        """Remove edge detection for a particular GPIO channel.  Pin should be
        type IN.
        """
        # do nothing due to the reason explained in add_event_detect

        # self.rpi_gpio.remove_event_detect(pin)

    def add_event_callback(self, pin, callback):
        """Add a callback for an event already defined using add_event_detect().
        Pin should be type IN.
        """
        # self.rpi_gpio.add_event_callback(pin, callback)

        # do nothing due to the reason explained in add_event_detect

    def event_detected(self, pin):
        """Returns True if an edge has occured on a given GPIO.  You need to 
        enable edge detection using add_event_detect() first.   Pin should be
        type IN.
        """
        # do nothing due to the reason explained in add_event_detect

        # return self.rpi_gpio.event_detected(pin)

    def wait_for_edge(self, pin, edge):
        """Wait for an edge.   Pin should be type IN.  Edge must be RISING,
        FALLING or BOTH.
        """

        # do nothing returns immediately

        # self.rpi_gpio.wait_for_edge(pin, self._edge_mapping[edge])

    def cleanup(self, pin=None):
        """Clean up GPIO event detection for specific pin, or all pins if none 
        is specified.
        """
        # do nothing due to the reason explained in add_event_detect

        # if pin is None:
        #     self.rpi_gpio.cleanup()
        # else:
        #     self.rpi_gpio.cleanup(pin)

写好了适配器之后,只需要在创建Adafruit_SSD1306对象时通过i2cgpio传递过去就好了

hardware = Adafruit_SSD1306.SSD1306_128_64(rst=None, i2c=OrangePiZeroI2C, gpio=OrangePiZeroGPIO, i2c_address=0x3C)

4.图形绘制

Adafruit_SSD1306库仅仅提供了向OLED硬件传输图形缓冲区的API,并不像U8G2那样同时提供了绘图API。所以我们需要使用另一个图形处理库Pillow来绘制我们想要的画面。

Pillow可以使用pip命令来安装,我个人比较推荐安装在virtualenv虚拟环境里,不会和全局包起冲突。

安装好了Pillow库之后,就可以创建缓冲区对象(buffer)和画笔对象(painter)了:

width, height = 128, 64
buffer = Image.new('1', (width, height)) # 1表示色彩位数,OLED只支持单色所以为1
painter = ImageDraw.Draw(buffer)

painter.rectangle((0, 0, 10, 20), fill=0, outline=1) # 绘制一个空心矩形
  • 缓冲区对象(buffer)是一个缓冲区对象,保存着当前画布的内容,或者叫画面。这些内容会通过Adafruit_SSD1306对象的API传输给OLED硬件。
  • 画笔对象(painter)是我们绘制各种图形时使用的对象,比如画一个矩形,线条,文字等

画面绘制好以后,就需要传输给OLED硬件进行显示了,方法很简单,只需要调用Adafruit_SSD1306对象的API进行加载,然后传输就可以。

hardware.image(buffer) # 加载并转换绘图缓冲区里的数据成OLED硬件能识别的格式
hardware.display() # 真正传输转换好的数据到OLED硬件

Pillow库提供了各种各样的绘图的API,无论多复杂的界面效果,都是由各种简单的绘图API组成的:

  1. 绘制圆弧
  2. 绘制位图
  3. 绘制椭圆
  4. 绘制线段
  5. 绘制扇形
  6. 绘制像素点
  7. 绘制多边形
  8. 绘制矩形
  9. 绘制文字

另外Pillow库有中文版本的翻译,虽然有些内容没有跟进,但借助翻译功能,还是能很轻松的阅读的:https://pillow-cn.readthedocs.io/zh_CN/latest/reference/ImageDraw.html

5.窗口主循环

每个窗口之间的逻辑关系类似于一个栈结构。因此无论何时总会有一个窗口处于栈顶,处于栈顶的这个窗口叫活跃窗口。

每一个窗口都有一个main_loop()主循环方法,当一个窗口被创建出来并被加入到栈顶时,这个窗口的main_loop()方法就会被执行,此时,这个窗口就获得了绘制线程的执行流。

在主循环方法内,你可以读取用户输入,绘制画面内容,也可以结束当前窗口,将执行流转交给父一级的窗口,也可以再开启一层新的窗口,并将执行流移交给新窗口。一个窗口的主逻辑都是在这个主循环方法里执行的。

当前窗口返回时(也叫结束时),可以将一些值返回给父一级的窗口,通过这个返回值,列表窗口可以把用户选择的选项索引返回给父一级窗口,并按次执行不同的逻辑。

通过窗口主循环方法这种设计,可以把所有的窗口打开关闭的关系串起来,而且在处理返回值的时候特别方便,不需要写专门的on_result()方法。整个窗口的逻辑都是写在一个单线程里的,逻辑非常清晰,而不是基于各种回调函数,写起来既不美观,又容易出bug。刷新率也比较容易控制,可以节省CPU性能,为后面做墨水屏的适配工作也会变得容易很多。

在我自己代码中我使用了Python协程来实现这一套逻辑,因为协程支持打断运行(cancel()方法)而线程不可以,线程一旦阻塞之后就非常难停止

6.伪终端PseudoTTY实现

伪终端PseudoTTY(PTY)就是模拟了一个真正的终端该有的样子,比如终端的宽度高度,还有各种前景色背景色参数,终端响铃,一般TTY有的PTY它都有。以及一些TUI程序(Terminal User Interface)比如vim、aptitude都必须要有完整的TTY环境才能运行,而仅有stdin和stdout是不够的。

PTY这个东西说起来比较抽象,我之前做过一个mirai机器人的插件,就是利用到了pty的概念模拟了一个终端,可以通过qq聊天框与这个终端进行透传。如果感兴趣的话可以翻找我的Github账号,如果对PTY概念感兴趣可以参考这篇文章:https://www.cnblogs.com/zzdyyy/p/7538077.html

PTY说的通俗一点就是在内存里模拟一个真正的终端(有宽度高度的那种),然后在窗口绘制每一帧画面的时候,都去读取一下这个终端每一个坐标上的字符内容,并将这些字符内容显示到绘制缓冲区里,发送给OLED硬件,最后看起来就是一个完整的终端屏幕啦。

我的代码里同样使用库实现,而非自己手写。分别用到了两个库pexpectpytepexpect用来模拟一个PTY终端,并向外提供一个输入输出流。而pyte负责将pexpect的输出流数据进行解析(主要指处理ANSI转义字符,详情参见维基百科:https://en.wikipedia.org/wiki/ANSI_escape_code )最后会变成一个几行几列的数组,我只需要读取这些数组里的字符串并将其打印到绘制缓冲区里就已经有了一个终端的效果了。

当然实际上还需要处理PTY终端输入,以及处理终端响铃的ANSI字符,获取光标位置和闪烁等一系列额外的代码。

PTY这部分可能只有专门做Linux偏底层的人员才能理解这个概念,因此我不做太过深入的讲解了。

IMG_20220706_164729

六、结语

到这里《从零开发低功耗NAS教程》就结束了,本来是打算分成4篇文章发布的,但一想这样挺占版面的,首页应该尽可能的多展示一些文章,最后还是合并到一篇文章里了,总之就是文章挺长的,感谢看到文章末尾的你!

如果想要源代码学(bai)习(piao)的话,可以点击这个链接(machinectl.zip )来下载到最新的源代码包,目前源码没有上传到GitHub等平台。

如果喜欢本项目的话,欢迎在下面留下评论与我讨论哦!