项目介绍
Funpack2-5任务三,使用ESP32-S3的联网功能以及BOX-LITE的屏幕实现在线电子书阅读器,通过板载ADC按键选择书目、章节并进行翻页。
硬件介绍
ESP-BOX 是乐鑫信息科技发布的新一代 AIoT 应用开发平台。ESP32-S3-BOX 和 ESP32-S3-BOX-Lite 是目前对应的 AIoT 应用开发板,搭载支持 AI 加速的 ESP32-S3 Wi-Fi + Bluetooth 5 (LE) SoC。他们为用户提供了一个基于语音助手 + 触摸屏控制、传感器、红外控制器和智能 Wi-Fi 网关等功能,开发和控制智能家居设备的平台。开发板出厂支持离线语音交互功能,用户通过乐鑫丰富的 SDK 和解决方案,能够轻松构建在线和离线语音助手、智能语音设备、HMI 人机交互设备、控制面板、多协议网关等多样的应用。
设计思路
电子书内容来自于网页端信息的爬取,而爬取HTML资源需要大量的字符串处理,会比较耗费内存,因此不是特别适合直接在开发板上进行。因此在该项目中我们使用pc服务器来爬取网页资源,获取到需要的信息后,再开放API接口给开发板,由开发板进行调用。
为了尽可能实现解耦,PC服务器获得的列表与内容都是网页上完整的内容,比如完整的章节列表,或是完整的整张内容;而具体的分页显示,与换行处理全部都在ESP32-S3上完成。因此这也对ESP32-S3提出了更高的要求,因为这需要足够大的内存来装完整的字符串与列表,并且还需要有足够的空间来进行换行与分屏处理。由于Circuitpython在内存管理上和PC上的python并不一样,因此代码中要特别注意内存的使用,将临时变量尽量晚创建,集中创建,并在使用完成后及时清理内存,避免出现碎片化的内存。
项目框图如下:
该项目使用Circuitpython作为开发平台进行开发,分为以下几个模块:
1,抓取小说服务器搭建
2,Wifi连接
3,分页显示
4,从服务器获取小说数据
5,按键控制
代码讲解
首先先是第一部分:
import requests
from bs4 import BeautifulSoup
import pandas as pd
class qidian:
def __init__(self) -> None:
pass
def getbook(self):
_url = "https://www.qidian.com/free/all/chanId0-action1/"
_response = requests.get(_url)
_soup = BeautifulSoup(_response.text, "html.parser")
_content = _soup.find("div",class_="all-book-list").find_all("li")
_df = pd.DataFrame(columns=['title', 'url'])
_i = 0
for _book in _content:
_df.loc[_i] = [_book.h2.a.text,"https:"+_book.h2.a.attrs["href"]]
_i += 1
_data = _df.to_json(orient="records")
return _data
def getchapter(self,url):
_url = url# + "#Catalog"
_response = requests.get(_url)
_soup = BeautifulSoup(_response.text, "html.parser")
_content = _soup.find("div",class_="catalog-all").find_all("li")
_df = pd.DataFrame(columns=['title', 'url'])
_i = 0
for _book in _content:
_df.loc[_i] = [_book.a.text,"https:"+_book.a.attrs["href"]]
_i += 1
_data = _df.to_json(orient="records")
return _data
def gettext(self,url):
_url = url
_response = requests.get(_url)
_soup = BeautifulSoup(_response.text, "html.parser")
_content = _soup.main.get_text().split('\u3000\u3000')[1:]
_content[0] = "\u3000\u3000" + _content[0]
_content = "\n\u3000\u3000".join(_content)
return _content
if __name__ == "__main__":
from json import loads
qd=qidian()
a = qd.getbook()
pass
这一部分代码是负责从网站上抓取小说。一共有三个方法,第一是获取免费小说列表,第二是根据提供的书目url获取章节列表,第三是根据提供的章节url获取章节内容。
接下来只需要一个简单的http服务器,打开以上代码的AI接口即可:
from flask import Flask, request
import json
from qidian import qidian
def func_qidian(data):
_func = data["func"]
try:
_url = data["url"]
except:
_url = None
print(_url)
_qd = qidian()
if _func == "getbook":
return _qd.getbook()
elif _func == "getchapter":
return _qd.getchapter(_url)
elif _func == "gettext":
return _qd.gettext(_url)
else:
return None
app = Flask(__name__)
@app.route("/qidian/", methods=["POST"])
def handle_qidian():
_data = request.get_json()
return func_qidian(_data)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8888, debug=True)
至此服务端的代码全部结束。接下来我们开始编写esp32-s3-box-lite的代码。
首先是wifi连接。circuitpython的wifi连接非常简单,不需要在主程序中添加任何代码,只需将SSID和PASWORD填入settings.toml就行。以下是settings.toml文件示例:
# To auto-connect to Wi-Fi
CIRCUITPY_WIFI_SSID="xxx"
CIRCUITPY_WIFI_PASSWORD="xxxxxxxx"
# To enable modifying files from the web. Change this too!
# Leave the User field blank when you type the password into the browser.
CIRCUITPY_WEB_API_PASSWORD=""
CIRCUITPY_WEB_API_PORT=80
API_URL="http://192.168.1.101:8888/qidian/"
在settings.toml文件中的内容其实就是全局环境变量的定义,最上面两个环境变量就是WIFI设置。开发板在重置后会首先尝试用以上两个环境变量去连接WIFI。这个文件也可以作为secrets文件使用,我将服务端地址也放在了这里,这样大家下载代码后,只需要修改这个文件就可以运行。
接下来是分页显示。circuitpython原生自带屏幕驱动支持,所以驱动屏幕也非常简单。但这个项目有所特殊的是需要显示大量的无法提前预支的中文字符,因此需要一个完整的中文字体。好在circuitpython支持bdf与pcf字体,因此我们可以下载现成的wenquanyi_12pt.pcf。初始化字体组件的代码如下:
color = 0x000000
font = bitmap_font.load_font("/wenquanyi_12pt.pcf")
text_area = label.Label(font, color=color)
text_area.y = 8
text_area.x = 2
text_area.background_color = None
text_area.line_spacing = 1.1
text_area.scale = 1
group.append(text_area)
这里我们按照功能一共有三种屏幕页面需要显示,一个是书目列表,一个是章节列表,还有最后一个是章节内容。我们使用一个index来确定当前需要显示哪个屏幕,并在屏幕中显示对应内容。内容获取就依靠request方法向我们刚刚搭建的http服务器发送请求。还有一些细节比如自动换行拉,超过一页的内容分页显示等等,就不再赘述。三个屏幕的显示代码如下:
def book_screen():
global screen_index
global book_page
global book_cursor
global chapter_page
global chapter_cursor
global url_chapter
global _url
arr.hide()
chapter_page = 0
chapter_cursor = 0
text_area.text = "加载中。。。"
_data = {"func": "getbook"}
_raw = requests.post(_url, json=_data).json()
_len = len(_raw)
_text = ""
_turn_page = True
_move_arr = 0
while True:
gc.collect()
if _turn_page:
arr.hide()
_text = ""
_range = range(book_page, book_page + line)
for i in _range:
_text = _text + "\u3000\u3000" + _raw[i]["title"] + "\n"
text_area.text = ""
text_area.text = _text
_turn_page = False
arr.pos(book_cursor)
arr.show()
if _move_arr == 1:
_move_arr = 0
arr.pos(book_cursor)
elif _move_arr == 2:
_move_arr = 0
book_page -= line
if book_page < 0:
book_cursor = line + book_page
book_page = 0
if book_cursor != 0:
_turn_page = True
else:
book_cursor = line - 1
_turn_page = True
elif _move_arr == 3:
_move_arr = 0
book_page += line
if book_page > (_len - line):
book_cursor = book_page - (_len - line) - 1
book_page = _len - line
if book_cursor != (line - 1):
_turn_page = True
else:
book_cursor = 0
_turn_page = True
else:
pass
_move_arr, book_cursor = updown(line, book_cursor, 1)
_sw = choose()
if _sw == 1:
_index = book_page + book_cursor
url_chapter = _raw[_index]["url"]
screen_index = 1
return
else:
pass
def chapter_screen(qidian_url):
global screen_index
global book_page
global book_cursor
global chapter_page
global chapter_cursor
global url_text
global _url
arr.hide()
text_area.text = "加载中。。。"
_data = {"func": "getchapter", "url":qidian_url}
_raw = requests.post(_url, json=_data).json()
_len = len(_raw)
_text = ""
_turn_page = True
_move_arr = 0
while True:
gc.collect()
if _turn_page:
arr.hide()
_text = ""
for i in range(chapter_page, chapter_page + line):
_text = _text + "\u3000\u3000" + _raw[i]["title"] + "\n"
text_area.text = ""
text_area.text = _text
_turn_page = False
arr.pos(chapter_cursor)
arr.show()
if _move_arr == 1:
_move_arr = 0
arr.pos(chapter_cursor)
elif _move_arr == 2:
_move_arr = 0
chapter_page -= line
if chapter_page < 0:
chapter_cursor = line + chapter_page
chapter_page = 0
if chapter_cursor != 0:
_turn_page = True
else:
chapter_cursor = line - 1
_turn_page = True
elif _move_arr == 3:
_move_arr = 0
chapter_page += line
if chapter_page > (_len - line):
chapter_cursor = chapter_page - (_len - line) - 1
chapter_page = _len - line
if chapter_cursor != (line - 1):
_turn_page = True
else:
chapter_cursor = 0
_turn_page = True
else:
pass
_move_arr, chapter_cursor = updown(line, chapter_cursor, 1)
_sw = choose()
if _sw == 1:
_index = chapter_page + chapter_cursor
url_text = _raw[_index]["url"]
screen_index = 2
return
elif _sw == 2:
screen_index = 0
return
else:
pass
def text_screen(qidian_url):
global screen_index
global _url
text_area.text="加载中。。。"
arr.hide()
_data = {"func": "gettext", "url":qidian_url}
_raw = wrap_text_to_pixels(requests.post(_url, json=_data).text,320,font=font)
_cursor = 0
_turn = True
while True:
gc.collect()
if _turn:
_text = ""
for i in range(_cursor,_cursor+line):
_text = _text + _raw[i].replace("-","") + "\n"
text_area.text=""
text_area.text=_text
_turn = False
_turn, _cursor = updown(len(_raw),_cursor,line)
_sw = choose()
if _sw == 2:
screen_index = 1
return
以上代码中还是用了板载的ADC按钮来进行选择与翻页,因此还需要对应的指针位置与状态控制,及确认与返回的控制函数。具体代码如下:
class arrow:
def __init__(self, group) -> None:
self.arrow_area = label.Label(terminalio.FONT, color=color, text="->")
self.arrow_area.y = 6
self.arrow_area.x = 8
self.group = group
def show(self):
try:
self.group.append(self.arrow_area)
except:
pass
def hide(self):
try:
self.group.remove(self.arrow_area)
except:
pass
def pos(self, index):
self.arrow_area.y = 6 + (index * 20)
arr = arrow(group)
def updown(length, cursor, step):
_left = btn.left()
_right = btn.right()
if _left == "press":
cursor -= step
if cursor < 0:
cursor = 0
return 2, cursor
return 1, cursor
if _right == "press":
cursor += step
if cursor > (length - step):
cursor = (length - step)
return 3, cursor
return 1, cursor
return 0, cursor
def choose():
global timer
global diff
global middle_state
_middle = btn.middle()
if middle_state:
if _middle == "press":
if not timer:
timer = time.monotonic()
else:
diff = time.monotonic() - timer
if diff > 1:
timer = 0
middle_state = False
return 2
return 0
else:
if timer > 0:
timer = 0
return 1
else:
return 0
else:
if _middle == "release":
middle_state = True
else:
pass
return 0
由于ESP32-S3-BOX-LITE使用的是ADC按键,相比普通按键略微复杂点,还需要区分长按短按,因此我写了一个单独的类用来驱动这个ADC按键,文件是/lib/litebtn.py,具体代码如下:
from analogio import AnalogIn
class LiteButton:
def __init__(self, pin):
self.pot = AnalogIn(pin)
self.left_base = 48000
self.right_base = 16000
self.middle_base = 40000
self.range = 4000
self.debounce = 1
self.left_press = 0
self.right_press = 0
self.middle_press = 0
def read(self, base_val):
_adc = self.pot.value
if (_adc > (base_val - self.range)) and (_adc < (base_val + self.range)):
return True
else:
return False
def state(self):
if self.read(self.left_base):
return "left"
elif self.read(self.right_base):
return "right"
elif self.read(self.middle_base):
return "middle"
else:
return
def left(self):
if self.left_press < self.debounce:
_state = self.read(self.left_base)
if _state:
self.left_press += 1
if self.left_press < self.debounce:
return
else:
return "press"
else:
return
else:
_state = self.read(self.left_base)
if _state:
return "press"
else:
self.left_press = 0
return "release"
def right(self):
if self.right_press < self.debounce:
_state = self.read(self.right_base)
if _state:
self.right_press += 1
if self.right_press < self.debounce:
return
else:
return "press"
else:
return
else:
_state = self.read(self.right_base)
if _state:
return "press"
else:
self.right_press = 0
return "release"
def middle(self):
if self.middle_press < self.debounce:
_state = self.read(self.middle_base)
if _state:
self.middle_press += 1
if self.middle_press < self.debounce:
return
else:
return "press"
else:
return
else:
_state = self.read(self.middle_base)
if _state:
return "press"
else:
self.middle_press = 0
return "release"
至此代码讲解全部结束。
功能展示
具体功能展示请参考上方视频,视频里已做详细的展示。本项目可以直接作为一个完成度相对较高的在线电子书浏览器使用。
列表选择与显示:
段落显示:
心得体会
自带了屏幕模块的开发板非常好用,省去了面包板接线的麻烦以及可能出现的接触不良或接错线带来的各种坑。Circuitpython也非常好用,在硬件驱动上已经替开发者完成了近乎全部工作,开发者只需要专心设计app就好。美中不足的是目前bug还比较多,我做这个项目的过程中死机了无数次。而circuitpython官网上也写目前对ESP32-S3的支持还在BETA阶段。相信未来CIRCUITPYTHON稳定后会成为主流的创客开发工具。