拔出式开发之用微雪墨水屏制作电子圣经
拔出式开发之用微雪墨水屏制作电子圣经
硬件配置
墨水屏的官网链接如下,全新的比较贵,能不能捡到便宜的垃圾各凭本事了:

板就是树莓派zero2w,当然zerow这种带wifi的也可以。
功能及效果展示
上电之后它会每隔一分钟(频率可调)随机显示一句《圣经》经文,可以根据经文条目的长短自动调整字号,保证显示不出格。在底栏中有现在时间及IP地址。底栏中的时间和经文同步刷新,每到整数分钟时刷新一次。由于墨水屏的特性,断电后屏幕不会清空,会保持在最后显示的页面上。

开发过程
环境及驱动配置
首先,千万不要看微雪的官方教程和任何涉及官方教程的民间帖子,比如误导我很久的这一篇。官方的教程已经不知道多久没更新了,想跑出来的话首先你要有一个python3.5的环境或者虚拟环境,当你扒拉2019年的老系统或者安了miniconda完成了这一步后,又要装一堆闻所未闻的奇怪库,碰到的各种报错也是够你喝一壶的。当你好不容易安上了要求的所有库,跑demo时又发现它的python代码居然本身是会报错的。也许靠着惊人的毅力最后可以跑出来,但是这玩意及其浪费时间、污染环境、占用空间,所以我及其不推荐。
我在踩了上述坑后,总结出了一个绝对能一次跑通的方法。
树莓派系统
直接安装最新的64位Lite系统即可,不用为了低版本python去找旧系统,否则很可能点不亮板子或者和后面要用到的库不兼容。注意要64位的Lite,两个因素缺一不可。

跑开源项目
我参考的是这个项目,它fork了另一个同功能的天气时钟仓库,但是教程更加细致。虽然我要做的功能和天气时钟不搭边,但跑这样一个项目能让我把环境配好。
至于具体的操作完全按这篇帖子来即可,它写得非常正确且详细。唯一要注意的是如果没有使用Pisugar电池模块,安装时让你选择版本时就选第一个即可,设用户名和密码时也可以随便写。如果使用了电池模块,就需要参考官方文档来正确填写这些信息。
期间还会遇到一些比较好解决的报错,比如有可能在安装request
包时遇到:
ImportError: urllib3 v2.0 only supports OpenSSL 1.1.1+, currently the ‘ssl‘ module is compiled
这是因为新版本的urllib3 需要OpenSSL 1.1.1+以上版本,只需降一个版本装上:
pip install urllib3==1.26.15
总之我在做的时候遇到的报错没有很复杂的,直接上网搜就能解决。如果有很复杂半天解决不了的,可能是前面的过程做错了。
这个项目跑出来的效果是这样一个带天气显示的时钟:

后面的项目代码就是在此基础上修改得到的。
项目代码编写
由于我没有查到任何有关的文档也懒得查,以下代码的编写方式全部来源于本人对开源项目的逆向工程,不一定是最高效的做法,但应该大差不差。
要实现上面说的那些功能,主要要解决经文的获取及显示、时间显示、IP地址显示和定时刷新,这几个功能的具体代码如下:
底栏中时间及IP的显示
首先,绘制底栏的函数如下:
time_position = 113
def Bottom_edge():
# 黑色底栏
draw.rectangle((2, 105, 248, 122), 'black', 'black')
# 时钟图标
draw.ellipse((time_position, 107, time_position+15, 120), 0, 255)
draw.line((time_position+7, 109, time_position+7, 114), fill=255, width=1)
draw.line((time_position+8, 114, time_position+12, 114), fill=255, width=1)
# 显示时间
draw.text((time_position+19, 107), get_date(), font=font05, fill=255) # 显示时间
# local_addr由后续获取IP的函数Get_ipv4_address()提供
global local_addr
local_addr = Get_ipv4_address()
# 显示IP地址
draw.text((10, 107), f"IP:{local_addr}", font=font05, fill=255)
墨水屏编程中所有的坐标都是左上角坐标,比如draw.text
函数的坐标参数就是显示出文本的左上角坐标。上边这段代码很直观地显示出了几乎所有要用到的绘图函数的用法,也是整个项目中最没技术含量的一部分,相信只要长了小脑都能看懂。接下来就是获取当前IP及时间的函数。
获取当前IPv4地址:
def Get_ipv4_address():
try:
# 用正则表达式筛选并获取IPv4地址字符串
ip_output = subprocess.check_output(
"hostname -I | grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}'", shell=True).decode('utf-8').strip()
ip_list = ip_output.split()
# 这里原项目中通过匹配筛选掉了172开头的私有地址,不过由于我在测试时
# 用的就是172地址,所以我去掉了这部分筛选功能
filtered_ips = [ip for ip in ip_list]
return filtered_ips[0] if filtered_ips else "No Internet."
except subprocess.CalledProcessError:
return "No Internet."
获取当前年月日及星期几:
def get_date():
global local_date
date = datetime.datetime.now()
week_day_dict = {0: 'Mon', 1: 'Tue', 2: 'Wed', 3: 'Thu', 4: 'Fri', 5: 'Sat', 6: 'Sun'}
day = date.weekday()
local_date = f"{date.strftime('%Y-%m-%d %H:%M')} {week_day_dict[day]}"
return local_date
这一步也很简单,通过映射表获取星期几的字符串并拼接格式化的时间字符串。考虑到墨水屏的刷新速度及使用寿命,时间只精确到分钟。
到这一步底栏已经绘制完成,具体怎么按分钟更新后边再说。
经文的获取及显示
要正确且美观地显示经文,就又引出了几个问题,接下来我会先提出问题再给出解决方式。
1、怎么获取一条随机的经文原文?
这部分我使用的api是:
url = "https://bible-api.com/data/web/random"
它会返回一个随机的经文条目,具体格式可以自己在浏览器访问试一下。
我们需要从中拿到经文原文和出处,出处包含book、chapter和verse。通过如下代码可以分别拿到这三部分,并把出处换行显示:
response = requests.get(url)
response.raise_for_status()
data = response.json()
verse = data["random_verse"]
text = verse["text"].strip().replace("\n", " ")
reference = f"- {verse['book']} {verse['chapter']}:{verse['verse']}"
2、墨水屏尺寸有限,怎么保证经文显示不出格(自动换行及字号调整)?
这部分马虎不得,一旦显示出格,不但会不美观,还会导致残留问题:后续刷新都是局部刷新,只刷新显示范围内的坐标,也就是说只要有一次显示出格,那出格的那部分就会被永久保留。如果出格到下面,很可能会覆盖掉时间和IP的显示,这种现象是不可接受的。
解决方法是一个如下的自动换行函数:
def wrap_text(text, max_width):
words = text.split()
lines = []
current_line = ""
for word in words:
# 如果本行加上下一个词就超过最大宽度了,就不增加了直接把这一行放入列表
if len(current_line) + len(word) + (1 if current_line else 0) > max_width:
lines.append(current_line)
# 还没来得及放入的词作为下一行的第一个词
current_line = word
else:
# 如果没有超过宽度限制,就放进去
current_line += (" " if current_line else "") + word
# 这一步用来处理最后一行
if current_line:
lines.append(current_line)
return lines
它接受两个参数,原文和最大宽度的限制。之所以把最大宽度做成一个参数而不是写死,是因为这个值你大概率要反复测试调整,真的很难一次写对。
这个函数巧妙地完成了按词换行的功能,避免了单词被截断造成的不美观。最后返回的数组中是一些字符串,每一个就是一行。
对于字号调整的问题,解决方法如下:
while True:
# 初步按 max_width=30 换行,判断行数
preview_lines = wrap_text(text, 30)
ref_lines = wrap_text(reference, 30)
total_lines = len(preview_lines) + len(ref_lines)
# 如果最大宽度为30时总行数超过了8行,就直接跳过这一条重新发请求
if total_lines > 8:
continue
# 根据行数动态调整宽度,如果总行数大于5行,
# 就在后续用更小的字号显示,这样一来就可以把行宽放大一些。
final_width = 36 if total_lines <= 5 else 46
# 最终换行
final_text_lines = wrap_text(text, final_width)
final_ref_lines = wrap_text(reference, final_width)
all_lines = final_text_lines + final_ref_lines
return "\n".join(all_lines), total_lines
它的返回值包含两部分,换行结果和总行数。返回总行数是用于后续显示时供我们判断选择字号用的。这样的写法考虑了各种情况,在保证观感的前提下动态调整显示,可以说万无一失,无论如何也不可能显示出格了。
注意,这一步的两个宽度需要亲自尝试调整,它受屏幕大小或者你的ui设计影响:
final_width = 36 if total_lines <= 5 else 46
3、如果经文非常长,就连最小的字号也不足以显示全它怎么办?
这个问题其实上面已经解决了,遇到这种的直接跳过,重新获取一条短的。这个问题我暂时认为无法被优雅地解决,因为墨水屏的刷新率很难做到滚动显示。如果非要做,就只能做成分页,即先显示前半部分,一分钟后再显示后半部分,不过目前我认为这样做没有必要,毕竟我只是想做个玩玩,不是真想刷功德。
按分钟刷新
这部分涉及到墨水屏的硬件特性及驱动库的使用。
我买的这块墨水屏是支持局部刷新的,即我不需要为了更新一个时间就整个刷新屏幕,但这就要求在编程时手动进行覆盖及局部强制刷新。
首先明确一下墨水屏绘图的机制。常见的绘图代码形如:
def outer_frame():
draw.line((2, 2, 248, 2), fill=0, width=1)
draw.line((2, 103, 2, 2), fill=0, width=1)
draw.line((248, 103, 248, 2), fill=0, width=1)
draw.line((2, 103, 248, 103), fill=0, width=1)
这也是我绘制经文显示区边框的代码。当你使用了draw
命令后,墨水屏并不会马上按要求更新,而是把你的操作放入刷新缓冲区,等待下次强制刷新时再统一更新。所以如果想要让它立即更新,需要一个局部强制刷新函数:
def Local_strong_brush():
for _ in range(3):
epd.displayPartial(epd.getbuffer(info_image.rotate(180)))
它会获取所有缓冲区中的更新队列,并自动局部刷新它们所在的坐标。可以看出这个函数只要执行就是连续更新3次,这是因为墨水屏的物理性质决定了它存在残留问题,如果只更新一次,屏幕上还是会浅浅地遗留上次显示的内容,刷三次是为了彻底把它刷干净。
有了这部分知识,就可以着手编写主函数了。
def Partial_refresh():
epd.displayPartBaseImage(epd.getbuffer(info_image.rotate(180)))
epd.init()
while True:
if int(time.time())%60 == 0:
# 设置时间刷新区域
draw.rectangle((111, 107, 248, 120), fill=0)
draw.ellipse((time_position, 107, time_position + 15, 120), 0, 255) # 时钟图标
draw.line((time_position + 7, 109, time_position + 7, 114), fill=255, width=1)
draw.line((time_position + 8, 114, time_position + 12, 114), fill=255, width=1)
draw.text((time_position + 19, 107), get_date(), font=font05, fill=255) # 显示时间
draw.rectangle((9, 6, 247, 102), fill=255)
result, lines = fetch_wrapped_bible_verse()
if (lines <= 5):
draw.text((10, 7), result, font=font06, fill=0)
else:
draw.text((10, 7), result, font=font04, fill=0)
Local_strong_brush()
global local_addr
local_addr1 = Get_ipv4_address()
if local_addr1 != local_addr:
draw.rectangle((2, 107, 123, 120), fill=0) # 设置IP地址刷新区域
draw.text((10, 107), f"IP:{local_addr1}", font=font05, fill=255) # 显示当前IP地址
local_addr = local_addr1
Local_strong_brush() # 局部强刷
这个函数会重复执行永不退出。可以看到开头的
if int(time.time())%60 == 0:
可以筛选出整数分钟,它保证了只有在每分钟开始时触发更新。如果要修改更新频率,只需更改取余的参数即可。另外,由于每分钟的经文和时间都需要刷新,不存在只有一个需要刷的情况,所以只在末尾进行一次局部强制刷新,而不是单独强刷。
对于ip地址,只要不变就没必要刷。
程序入口
这部分主要是如何初始化epd(墨水屏对象):
while True:
try:
epd = epd2in13_V4.EPD() # 初始化
epd.init() # 设定屏幕刷新模式
info_image = Image.new('1', (epd.height, epd.width), 255) # 画布创建准备
draw = ImageDraw.Draw(info_image)
outer_frame()
Bottom_edge()
Bible()
Partial_refresh() # 局部刷新
epd.init()
epd.Clear(0xFF)
epd.sleep()
time.sleep(20)
break # 如果脚本执行成功,则退出循环(也不可能)
except (OSError, Exception) as e: # 捕获异常
logging.error("Error: %s", e)
time.sleep(retry_interval) # 等待一段时间后重试
except KeyboardInterrupt:
epd.sleep()
epd2in13_V4.epdconfig.module_exit()
exit()
# 这是程序正常退出时的收尾工作,虽然这个代码不太可能能正常退出。
epd.init()
epd.sleep()
epd2in13_V4.epdconfig.module_exit()
exit()
这部分照抄即可。它的核心部分是:
outer_frame()
Bottom_edge()
Bible()
Partial_refresh()
前三个是系统初始化,也是必不可少的。因为你启动系统时很刚好卡在整数分钟,而Partial_refresh()
又只在整数分钟触发,所以在这几十秒的时间内也要有东西立即显示出来。
上电自动启动
通过一个.sh
脚本实现,具体内容大概是:
cd <your project directory> &&
python <your main python file>
把它放在/etc/init.d/
目录下,reboot后就能看到屏幕自动启动了。当然,别忘了chmod +x
授权。
改为无需联网的版本
由于开发板连wifi是一个很困难的事,在开发环境下它能自动连接我的wifi,但是如果到一个新环境下它很可能连不上网。那样的话脚本就会报错。而且,避免发网络请求也能进一步降低功耗。
解决这个问题的方法是提前准备一个json文件,其中放所有经文条目的书名及章节信息,需要的时候直接随机取。
准备json文件
我能找到的唯一一个圣经数据集是来自open-bibles,下载其中的eng-asv.osis.xml。xml是一个树状文件,每次读取时都要先读整个文件进内存才能开始检索,这样功耗会很大,也会变慢。所以我需要把它转换成一个json格式的文件放在板子上,转换代码很好写,此处略过。
更改随机获取逻辑并优化
原本的随机获取逻辑大概流程是循环发请求获取随机请问,直到拿到一条能够显示得下的。现在既然数据都在本地了,就可以提前先把所有过长显示不下的条目删掉。遍历json,应用如下的check方法:
def check(text):
preview_lines = wrap_text(text, 30)
total_lines = len(preview_lines) + 1
if total_lines > 8:
return False
return True
就能得到一个filtered json。
只需把发网络请求获取json的部分替换成如下方法:
def random_verse_from_json():
if not entries:
raise ValueError("No entries found in the JSON file.")
entry = random.choice(entries)
return entry['text'], entry['bname'], entry['cnumber'], entry['vnumber']
最后,把加载json放在最开头,不要放在函数里,这样脚本执行后只需要读一次盘,节省很多时间:
# 提前加载到内存,避免重复读盘
with open('bible_entries.json', 'r', encoding='utf-8') as f:
entries = json.load(f)