公司动态
基于树莓派与电子墨水屏的Pi-hole网络状态显示器DIY教程
1. 项目概述当网络“清道夫”遇见墨水屏如果你和我一样家里有一堆智能设备从手机、平板到智能音箱、电视盒子甚至智能灯泡那你肯定对无处不在的广告和追踪器深恶痛绝。几年前我接触到了Pi-hole这个在树莓派上运行的开源网络广告拦截器它瞬间成了我家网络的“守护神”。它不像浏览器插件那样只保护单个设备而是在路由器层面为整个局域网内的所有设备提供广告屏蔽服务效果立竿见影。但Pi-hole有个“小问题”它的管理界面虽然功能强大但通常需要打开浏览器输入IP地址才能查看状态。对于我这种喜欢把设备状态“摆在台面上”的人来说总感觉少了点仪式感和即时性。直到我看到了手边闲置的一块电子墨水屏。电子墨水屏也就是e-ink屏以其类纸质感、超低功耗和常显特性著称常被用于电纸书。一个想法自然浮现为什么不把Pi-hole的关键数据比如拦截请求数、查询总数、设备列表实时显示在这块墨水屏上呢这样我只需瞥一眼桌面就能对家庭网络的“健康状况”了如指掌。这个“Pi-hole e-ink Display”项目就是将这两者结合的产物。它不仅仅是一个状态显示器更是一个融合了网络管理、数据可视化和硬件DIY的趣味项目。通过它你可以直观地看到Pi-hole如何默默为你拦截成千上万的广告请求感受网络世界“净化”的实时反馈。无论你是Pi-hole的资深用户还是对树莓派和电子墨水屏感兴趣的硬件爱好者这个项目都能带来不小的乐趣和实用价值。2. 项目核心思路与架构设计2.1 为什么选择电子墨水屏在决定用屏幕显示Pi-hole数据时我考虑过几种方案小型LCD屏、OLED屏以及最终选择的电子墨水屏。每种方案都有其优缺点我的选择基于以下几个核心考量首先是功耗与显示特性。电子墨水屏只在刷新画面时消耗电能一旦画面显示完成即使断电图像也能持续保留。这意味着我可以让这个显示器7x24小时常亮而不用担心电费飙升或屏幕烧屏。这对于一个需要持续监控状态的项目来说是理想特性。相比之下LCD或OLED屏需要持续背光或像素自发光功耗高得多且不适合长期静态显示。其次是视觉体验与环境融合。e-ink屏幕无反光、不刺眼看起来像印刷在纸上的文字非常适合放在书桌、书架或任何家居环境中不会像发光屏幕那样在夜间成为光污染源。它的存在感很低但信息呈现又很清晰完美符合“瞥一眼即知”的需求。最后是项目的“极客”趣味性。使用e-ink屏本身就是一个有趣的硬件挑战。它的驱动方式与普通屏幕不同涉及波形文件、局部刷新、全局刷新等概念能让你更深入地理解一种显示技术的工作原理。将这种“慢速”显示技术与实时网络数据结合本身就有一种技术碰撞的美感。2.2 系统整体架构与数据流整个项目的架构可以清晰地分为三层数据源层、处理层和显示层。数据源层核心就是运行Pi-hole的树莓派我们称之为Pi-hole主机。Pi-hole提供了一个功能完善的API我们可以通过向特定的本地URL发送HTTP GET请求获取结构化的JSON数据。这些数据包含了我们需要的所有信息例如ads_blocked_today: 今日拦截的广告数量。dns_queries_today: 今日处理的DNS查询总数。unique_clients: 独特的客户端设备数量。status: Pi-hole的运行状态启用或禁用。甚至可以获取按域名或客户端排序的查询排行榜。处理层是本项目的大脑。它需要运行在能够连接墨水屏的设备上。最经典的方案是使用另一块树莓派我们称之为显示主机当然如果条件允许你也可以让Pi-hole主机“身兼二职”既跑Pi-hole服务又运行显示程序。处理层的主要任务是定时抓取通过Python脚本每隔一段时间如10秒、30秒或1分钟调用Pi-hole的API获取最新数据。数据处理与格式化解析JSON数据计算出我们需要显示的指标比如拦截率ads_blocked_today / dns_queries_today * 100%。然后将这些数字和文本组织成适合在有限屏幕上显示的布局。图像生成利用Python的PILPillow库在内存中创建一张与墨水屏分辨率匹配的图像如800x480设置背景色白色用黑色绘制文字、绘制简单的图表如用条形图表示过去几小时的请求趋势。驱动屏幕刷新调用墨水屏厂商提供的Python库如waveshare_epd将生成好的图像数据发送给屏幕并指令其刷新显示。显示层就是电子墨水屏硬件本身。它通过SPI或I2C接口与树莓派连接接收处理层发来的图像数据并完成物理刷新。注意这里存在一个关键决策点——是使用“一体机”方案Pi-hole和显示程序在同一台树莓派上还是“分离式”方案两台设备。一体机方案更简洁节省硬件但可能会对Pi-hole主机的性能产生轻微影响尤其是低配版树莓派。分离式方案更清晰互不干扰且显示主机可以放在任何有网络连接的地方。我选择了分离式因为我家原本就有一台专跑Pi-hole的树莓派4B再用一块闲置的树莓派Zero W来驱动屏幕成本可控架构清晰。3. 硬件准备与软件环境搭建3.1 硬件选型清单与连接这个项目对硬件的要求相对灵活以下是我的配置你可以根据手头资源调整显示主机树莓派 Zero W。选择它的原因是功耗极低、体积小巧、价格便宜且自带Wi-Fi非常适合作为专职的显示终端。树莓派3B/3B/4B当然也可以性能更强但功耗和体积也更大。电子墨水屏我使用的是 Waveshare 7.5英寸 V2 黑白三色黑、白、红电子墨水屏分辨率800x480。选择7.5英寸是因为显示面积足够大可以容纳较多信息。三色屏红白黑可以让关键数据如拦截数用红色突出显示视觉效果更好。当然普通的黑白屏完全够用。Micro SD卡至少8GB容量用于安装树莓派操作系统。电源与连接线为树莓派Zero W供电的Micro USB电源线。墨水屏通常通过GPIO引脚与树莓派连接需要根据屏幕型号准备对应的排线。硬件连接步骤首先确保树莓派未通电。参照墨水屏产品说明书将屏幕的SPI接口涉及MOSI,MISO,SCLK,CS,DC,RST,BUSY等引脚与树莓派Zero W的对应GPIO引脚连接。通常需要用到杜邦线或直接插接。务必仔细核对引脚定义接错可能损坏设备。连接好后为树莓派接通电源。3.2 显示主机系统与驱动安装首先需要在显示主机树莓派Zero W上搭建基础环境。安装操作系统使用 Raspberry Pi Imager 工具将 Raspberry Pi OS Lite无桌面环境的精简版烧录到SD卡中。在烧录前通过Imager的“高级选项”预先配置Wi-Fi和国家、开启SSH服务、设置用户名密码。这样烧录好的SD卡插入树莓派通电后就能自动连接网络并通过SSH访问无需外接显示器键盘。系统初始化与更新通过SSH登录树莓派后首先执行系统更新。sudo apt update sudo apt upgrade -y安装Python3及必要库本项目主要使用Python3。sudo apt install python3-pip python3-pil python3-numpy -y安装墨水屏驱动库以Waveshare屏幕为例通常厂家会提供Python库。可以通过pip安装或从GitHub克隆。# 方法一通过pip安装如果厂家提供了pip包 # pip3 install waveshare-epd # 方法二从GitHub克隆更通用 sudo apt install git -y git clone https://github.com/waveshare/e-Paper.git cd e-Paper/RaspberryPi_JetsonNano/python sudo pip3 install .安装完成后可以运行厂家提供的示例程序如epd_7in5_V2_test.py来测试屏幕是否能正常驱动。如果屏幕能正确显示测试图片说明硬件连接和驱动安装成功。3.3 Pi-hole API访问准备在显示主机上我们需要能够访问Pi-hole主机的管理API。这需要两样东西Pi-hole的IP地址和API令牌。获取Pi-hole IP地址在你的Pi-hole主机上使用hostname -I命令查看其IP地址假设为192.168.1.100。获取API令牌登录Pi-hole的Web管理界面http://192.168.1.100/admin。在左侧菜单栏进入Settings-API / Web interface。在API选项卡中你会看到Show API token按钮点击它并复制显示出来的长字符串。这就是你的API密钥请妥善保管。测试API连通性在显示主机上我们可以用curl命令测试是否能获取数据。API的基本格式是http://pi-hole_ip/admin/api.php?summaryauthyour_api_tokencurl http://192.168.1.100/admin/api.php?summaryauth你的API令牌如果返回一串JSON数据说明访问成功。如果失败请检查显示主机是否能ping通Pi-hole主机ping 192.168.1.100。API令牌是否正确且Pi-hole的Web接口设置中允许了API访问。4. 核心代码实现与解析4.1 数据获取与解析模块我们首先编写一个负责与Pi-hole API通信并解析数据的Python模块。创建一个文件比如叫pihole_client.py。import requests import json from datetime import datetime, timedelta import time class PiHoleClient: def __init__(self, host, api_token): self.host host.rstrip(/) self.api_token api_token self.base_url fhttp://{self.host}/admin/api.php def get_summary_data(self): 获取Pi-hole摘要数据 params { summary: , auth: self.api_token } try: response requests.get(self.base_url, paramsparams, timeout10) response.raise_for_status() # 检查HTTP错误 data response.json() return data except requests.exceptions.RequestException as e: print(f获取Pi-hole数据失败: {e}) # 返回一个空字典或包含错误信息的数据避免显示脚本崩溃 return {} def format_data(self, raw_data): 格式化原始数据提取我们需要显示的信息 if not raw_data: return { status: ERROR, queries: 0, blocked: 0, percent: 0.0, clients: 0, update_time: time.strftime(%H:%M) } queries raw_data.get(dns_queries_today, 0) blocked raw_data.get(ads_blocked_today, 0) percent raw_data.get(ads_percentage_today, 0.0) # 计算拦截率如果原始数据没有则手动计算 if queries 0 and percent 0: percent (blocked / queries) * 100 formatted { status: ENABLED if raw_data.get(status) enabled else DISABLED, queries: queries, blocked: blocked, percent: round(percent, 1), # 保留一位小数 clients: raw_data.get(unique_clients, 0), update_time: time.strftime(%H:%M) # 当前时间 } return formatted # 使用示例 if __name__ __main__: # 替换成你自己的Pi-hole地址和API令牌 client PiHoleClient(192.168.1.100, your_api_token_here) summary client.get_summary_data() print(原始数据:, json.dumps(summary, indent2)) formatted client.format_data(summary) print(\n格式化后:, formatted)这个类封装了数据获取和初步处理逻辑。get_summary_data方法负责调用APIformat_data方法则从返回的JSON中提取出我们关心的几个核心字段并进行格式化如计算百分比、四舍五入。异常处理很重要确保网络波动或Pi-hole服务重启时显示程序不会崩溃而是显示错误状态或上一次的有效数据。4.2 电子墨水屏绘图与显示模块接下来是核心的显示部分。创建一个文件比如叫eink_display.py。这里以Waveshare 7.5英寸V2屏幕为例。from PIL import Image, ImageDraw, ImageFont import epaper import time import os class EinkDisplay: def __init__(self): # 初始化屏幕具体型号根据你的屏幕修改 self.epd epaper.epaper(epd7in5_V2).EPD() self.width self.epd.width # 800 self.height self.epd.height # 480 # 初始化字体如果系统没有可以指定字体文件路径 try: self.font_large ImageFont.truetype(/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf, 48) self.font_medium ImageFont.truetype(/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf, 36) self.font_small ImageFont.truetype(/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf, 24) self.font_tiny ImageFont.truetype(/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf, 18) except: # 如果找不到字体使用默认字体可能不太美观 self.font_large ImageFont.load_default() self.font_medium ImageFont.load_default() self.font_small ImageFont.load_default() self.font_tiny ImageFont.load_default() print(警告未找到指定字体使用默认字体。) def create_status_image(self, data): 根据数据创建要显示的图像 # 创建一张新图像初始为白色1表示白色 image Image.new(1, (self.width, self.height), 255) draw ImageDraw.Draw(image) # 1. 绘制标题和状态 title Pi-hole Network Guard status_text fStatus: {data[status]} status_color 0 # 黑色 if data[status] ENABLED: status_color 0 # 黑色正常 elif data[status] DISABLED: status_color 0 # 黑色但可以加个框提示 else: # ERROR status_color 0 draw.text((20, 20), title, fontself.font_large, fill0) draw.text((self.width - 200, 30), status_text, fontself.font_small, fillstatus_color) # 2. 绘制核心数据区块 y_offset 100 # 查询总数 draw.text((50, y_offset), DNS Queries Today:, fontself.font_medium, fill0) draw.text((400, y_offset), f{data[queries]:,}, fontself.font_medium, fill0) # 千位分隔符 # 拦截数 - 如果用三色屏这里可以用红色突出显示 draw.text((50, y_offset 60), Ads Blocked:, fontself.font_medium, fill0) # 假设我们有一个方法可以画红色对于黑白屏就用黑色加粗效果 blocked_text f{data[blocked]:,} # 对于黑白屏通过描边或背景框来“突出” bbox draw.textbbox((400, y_offset 60), blocked_text, fontself.font_medium) draw.rectangle(bbox, outline0, width2) # 画一个框 draw.text((400, y_offset 60), blocked_text, fontself.font_medium, fill0) # 3. 绘制拦截率进度条 bar_y y_offset 130 bar_width 700 bar_height 30 bar_x_start 50 # 背景条灰色通过画一个填充矩形并描边模拟 draw.rectangle([bar_x_start, bar_y, bar_x_start bar_width, bar_y bar_height], outline0, fill255) # 前景条根据百分比填充 fill_width int((data[percent] / 100.0) * bar_width) if fill_width 0: draw.rectangle([bar_x_start, bar_y, bar_x_start fill_width, bar_y bar_height], fill0) # 百分比文字 percent_text f{data[percent]}% text_width draw.textlength(percent_text, fontself.font_small) draw.text((bar_x_start bar_width 10, bar_y), percent_text, fontself.font_small, fill0) # 4. 绘制客户端数和更新时间 draw.text((50, bar_y 50), fUnique Clients: {data[clients]}, fontself.font_small, fill0) draw.text((self.width - 150, self.height - 40), fUpdated: {data[update_time]}, fontself.font_tiny, fill0) # 5. 绘制一个简单的分隔线 draw.line([20, 80, self.width - 20, 80], fill0, width2) return image def display_image(self, image): 将图像显示到墨水屏上 # 初始化屏幕 self.epd.init() # 清屏可选通常刷新新内容前不需要全刷但为了效果干净可以偶尔做一次 # self.epd.Clear() # 将PIL图像转换为屏幕缓冲区能接受的格式并显示 self.epd.display(self.epd.getbuffer(image)) # 进入睡眠模式以省电 self.epd.sleep() def clear_screen(self): 清空屏幕全白 self.epd.init() self.epd.Clear() self.epd.sleep()这个类负责所有与屏幕绘制相关的逻辑。create_status_image方法是核心它使用PIL库在内存中“画”出我们想要的界面布局。这里设计了几个区域标题状态区、核心数据区、进度条、辅助信息区。布局和样式你可以根据自己的审美和屏幕尺寸自由调整。display_image方法则负责将画好的图像推送到物理屏幕并刷新。重要心得电子墨水屏的刷新比较慢可能需要2-3秒且频繁全局刷新会影响屏幕寿命。因此在定时刷新数据时我们应尽量使用局部刷新如果屏幕驱动支持。对于Waveshare V2屏幕可以使用self.epd.displayPartial方法进行局部刷新这需要更精细地控制刷新区域。在我的实践中对于每分钟一次的数据更新使用局部刷新完全足够屏幕几乎看不到残影且寿命更长。具体用法需参考屏幕驱动的文档。4.3 主循环与定时任务整合最后我们需要一个主程序把数据获取和显示串联起来并实现定时运行。创建main.py。#!/usr/bin/env python3 import time import signal import sys from pihole_client import PiHoleClient from eink_display import EinkDisplay # 配置信息 PI_HOLE_HOST 192.168.1.100 # 你的Pi-hole IP API_TOKEN your_actual_api_token_here # 你的API令牌 REFRESH_INTERVAL 60 # 刷新间隔单位秒 # 全局变量用于优雅退出 running True def signal_handler(sig, frame): global running print(接收到退出信号正在清理...) running False def main(): # 注册信号处理使程序能通过CtrlC优雅退出 signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) # 初始化客户端和显示器 print(初始化Pi-hole客户端和墨水屏...) pihole PiHoleClient(PI_HOLE_HOST, API_TOKEN) display EinkDisplay() # 首次启动清屏并显示初始化信息可选 # display.clear_screen() init_image display.create_status_image({ status: BOOTING, queries: 0, blocked: 0, percent: 0.0, clients: 0, update_time: time.strftime(%H:%M) }) display.display_image(init_image) time.sleep(2) error_count 0 MAX_ERRORS 5 # 连续错误最大次数 print(f开始监控循环每 {REFRESH_INTERVAL} 秒刷新一次。) while running: try: # 1. 获取数据 raw_data pihole.get_summary_data() formatted_data pihole.format_data(raw_data) # 2. 创建图像 image display.create_status_image(formatted_data) # 3. 更新显示 display.display_image(image) # 打印日志到控制台可选 print(f[{formatted_data[update_time]}] Queries: {formatted_data[queries]:,} | fBlocked: {formatted_data[blocked]:,} ({formatted_data[percent]}%) | fClients: {formatted_data[clients]}) error_count 0 # 成功则重置错误计数 except Exception as e: error_count 1 print(f更新显示时发生错误 ({error_count}/{MAX_ERRORS}): {e}) if error_count MAX_ERRORS: print(错误次数过多程序退出。) break # 出错时等待稍短时间再重试 time.sleep(10) continue # 4. 等待下一个周期 sleep_remaining REFRESH_INTERVAL while sleep_remaining 0 and running: time.sleep(min(10, sleep_remaining)) # 每10秒检查一次运行状态 sleep_remaining - 10 # 循环结束清理屏幕显示再见信息或清屏 print(程序退出清屏。) display.clear_screen() if __name__ __main__: main()这个主程序实现了一个简单的循环获取数据 - 生成图片 - 刷新屏幕 - 等待。它包含了错误处理机制当连续多次无法获取数据时程序会退出避免陷入死循环。同时通过捕捉SIGINTCtrlC和SIGTERM信号实现了程序的优雅退出在退出前会清空屏幕。5. 部署、优化与问题排查5.1 系统服务化部署与自启动为了让这个显示器在树莓派开机后自动运行并在崩溃后能自动重启最好的方法是将它配置为一个系统服务。创建服务文件在树莓派上创建一个新的systemd服务文件。sudo nano /etc/systemd/system/pihole-display.service编辑服务内容将以下内容粘贴进去注意修改WorkingDirectory和ExecStart的路径为你项目文件的实际存放位置例如/home/pi/pihole_eink_display。[Unit] DescriptionPi-hole E-ink Display Service Afternetwork.target multi-user.target Wantsnetwork.target [Service] Typesimple Userpi WorkingDirectory/home/pi/pihole_eink_display ExecStart/usr/bin/python3 /home/pi/pihole_eink_display/main.py Restarton-failure RestartSec10 StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-user.targetRestarton-failure确保程序意外退出时会自动重启。RestartSec10设置重启前等待10秒。Userpi指定以pi用户运行确保有正确的权限访问GPIO和家目录文件。启用并启动服务sudo systemctl daemon-reload sudo systemctl enable pihole-display.service sudo systemctl start pihole-display.service检查服务状态sudo systemctl status pihole-display.service如果看到active (running)说明服务已成功启动。你还可以通过journalctl -u pihole-display.service -f来实时查看日志输出。5.2 显示效果优化与高级功能基础功能实现后可以考虑以下优化来提升体验局部刷新优化如前所述将eink_display.py中的display_image方法改为使用局部刷新。这需要修改驱动调用并可能需计算图像变化的区域。这能极大提升刷新速度并保护屏幕。# 在EinkDisplay类的display_image方法中尝试替换 # self.epd.display(self.epd.getbuffer(image)) # 为具体函数名请参考你的驱动文档 # self.epd.displayPartial(self.epd.getbuffer(image))历史趋势图在屏幕上增加一个简单的条形图显示过去几个小时内的查询或拦截趋势。这需要在主程序中维护一个固定长度的历史数据列表如过去12小时每小时一个点并在绘图时将其可视化。多屏信息轮播如果屏幕空间允许可以设计多个“页面”比如第一页显示摘要第二页显示Top Blocked Domains通过API获取topItems第三页显示客户端列表。通过一个物理按钮连接GPIO或定时自动切换页面。网络重连机制在PiHoleClient类中增加更健壮的网络错误处理。如果获取失败可以尝试多次重试并记录到本地日志文件而不是简单地返回空数据。美化UI使用更精美的字体可以从网上下载.ttf字体文件放到项目目录、设计Logo、调整布局和颜色对于三色屏。PIL库功能强大可以绘制圆角矩形、图标等。5.3 常见问题与排查实录在搭建和运行过程中你可能会遇到以下问题问题现象可能原因排查步骤与解决方案屏幕一片空白或显示乱码1. 电源不足。2. GPIO引脚接错。3. 驱动型号不匹配。1. 使用额定电流足够的电源5V 2A以上。2.断电后仔细对照屏幕和树莓派引脚图确保每根线都正确。3. 检查eink_display.py中初始化的屏幕型号如epd7in5_V2是否与你购买的完全一致。运行厂家提供的测试程序先行验证。程序启动后报错ImportError: No module named epaper墨水屏驱动库未正确安装。1. 确认是否进入了驱动库目录e-Paper/RaspberryPi_JetsonNano/python执行了sudo pip3 install .。2. 尝试全局安装sudo pip3 install /path/to/e-Paper/RaspberryPi_JetsonNano/python。3. 检查Python路径。API调用失败返回401错误或空数据1. Pi-hole地址或API令牌错误。2. Pi-hole的Web设置未启用API。1. 在显示主机上用curl命令手动测试API地址和令牌。2. 登录Pi-hole管理界面在Settings - API / Web interface中确认“API”已启用。屏幕刷新后残影严重刷新模式使用不当。1. 确保在显示静态内容时使用局部刷新displayPartial。2. 每隔一段时间如24小时或每次程序启动时执行一次全屏刷新Clear以消除残影。可以在主循环中加入计数器来实现。程序运行一段时间后卡死或无响应1. 内存泄漏PIL图像未释放。2. 网络请求超时未处理。3. 屏幕驱动库存在bug。1. 确保在每次循环中创建的PIL Image对象在display_image调用后能被垃圾回收。可以显式调用image None。2. 为requests.get设置合理的timeout参数如10秒并做好异常捕获。3. 查看系统日志journalctl -u pihole-display.service看是否有Python报错。尝试更新屏幕驱动库到最新版本。系统服务无法启动1. 服务文件语法错误。2. 路径或权限问题。3. Python依赖缺失。1. 使用sudo systemctl status pihole-display.service查看详细错误信息。2. 确保WorkingDirectory和ExecStart中的路径存在且可执行。3. 尝试手动在项目目录下运行python3 main.py看是否报错。可能需要将依赖库如requests,Pillow安装到系统全局环境或使用虚拟环境并在服务文件中指定。我个人在实际操作中的体会是硬件项目最磨人的往往是第一步——硬件连接和基础驱动。只要屏幕能点亮并显示测试图案后面的代码部分就相对顺畅。对于网络部分一定要做好异常处理家庭网络环境并非百分百稳定。另外电子墨水屏的刷新逻辑需要一点时间去适应它不是即时的那种“慢吞吞”的刷新过程本身也成了一种独特的体验仿佛数据是缓缓“印”在屏幕上的每次刷新都多了一份期待感。这个项目完成后那块静静立在桌角的墨水屏不仅是一个实用的网络状态监视器更像一个数字化的“网络守门员”勋章无声地展示着背后Pi-hole的辛勤工作。你可以根据自己的想法无限扩展它比如加上室内温湿度传感器读数、日历事件甚至是一个简单的待办事项列表让它成为一个真正的多功能桌面信息中心。