文章背景图

开发板开发笔记

2026-06-29
36
-
- 分钟

买了一块开发板,鲁班猫a0,H618芯片。安装了Ubuntu当成服务器和NAS用,下面是我的开发过程

连接

两种办法串口连接和ssh。

串口连接

刚买来没网络环境我用的串口连接,官网都有教程。但是我的系统是debian所以官网上的教程有的不适用,我选择的串流软件是minicom。首先插上串口,然后查看串口设备文件

ls -l /dev/ttyUSB* /dev/ttyACM* 2>/dev/null

安装配置minicom

# 安装
sudo apt install minicom
# 修改配置
sudo minicom -s

按 A 设置串口设备为 /dev/ttyUSB0,按 E 设置波特率115200,按 F 关闭设为 No,选择“保存设置为 dfl”,然后“离开本画面”,就进入lubancat系统内了。

进入系统第一步应该是连接网络并配置静态ip,官方文档配置静态ip顺序有点错误,正确的应该是

  1. 连接wifi

    sudo nmtui
  2. 查看网关

    route -n
  3. 设置静态ip

比较长,直接查看官方文档,写的很清楚。https://doc.embedfire.com/linux/h618/quick_start/zh/latest/quick_start/network/network2.html#id13

SSH连接

首先要在同一个局域网内,然后要知道ip地址。 这个直接参照官方文档https://doc.embedfire.com/linux/h618/quick_start/zh/latest/quick_start/login/login.html#ssh

系统配置与服务

网络配置

网络代理

因为是纯命令行所以用的crash

#安装
sudo apt install crash
#运行
crash

这样启动有可能权限不够启动失败,所以我换启动脚本

sudo /etc/ShellCrash/menu.sh

接下来就和正常配置网络代理一样了,正常导入订阅,如果订阅不能用的话就更换内核试试,别忘设置开机自启,不需要自己设置,脚本里面有快捷配置。

内网穿透

本来打算使用ipv6但是送的路由器太垃圾了不支持打开ipv6的防火墙,然后使用ddnsgo绑定域名,但是还是因为ipv6没办法连到公网,没招了我就选择zerotierhttps://www.zerotier.com/。注意设置的穿透的地址别和实际的ip地址一样。

服务

1panel面板

有一个面板管理起来很方便,而且部署和管理docker看日志也很方便。https://1panel.cn/

casaOS

这个东西没太大用,主要是刚开始不知道要搞什么,算是一个NAS系统吧,用官方文档里的一键安装命令安装。https://doc.embedfire.com/linux/h618/quick_start/zh/latest/lubancat_allwinner_software_hardware/software/CasaOS/casaos.html#id1

Telegram下载机器人

这个是最爽的,telegram里创建一个频道,视频转发进里面就自动下载。需要向botfather申请一个机器人向官方申请开发者api,服务器上运行telegram的服务器端。

pip3 install telethon
pip3 install cryptg

下面是ai写的脚本,我的路径在/opt/bot.py。

from telethon import TelegramClient, events, Button
from telethon.tl.types import DocumentAttributeVideo
import asyncio, os, shutil, subprocess, time
from collections import deque

# ================= 核心配置区 =================
api_id = 
api_hash = ''
bot_token = ''
proxy = ('http', '127.0.0.1', 7890)
target_chat = -1003744786905
BASE_DIR = '/media/nas/Media'

DIRS = {
    "1": {"name": "🎬 电影", "path": f"{BASE_DIR}/Movies"},
    "2": {"name": "📺 剧集", "path": f"{BASE_DIR}/TVShows"},
    "3": {"name": "📂 默认视频", "path": f"{BASE_DIR}/Video"}
}
# =============================================

client = TelegramClient('/opt/real_bot_session', api_id, api_hash, proxy=proxy)

# 全局状态管理
pending_tasks = {}
active_rename_session = {}
download_queue = asyncio.Queue()
download_history = deque(maxlen=10)
current_download_task = {}

# 工具函数:格式化大小、时长、速度、时间
def format_size(bytes_size):
    if bytes_size < 1024**2: return f"{bytes_size/1024:.1f} KB"
    elif bytes_size < 1024**3: return f"{bytes_size/(1024**2):.1f} MB"
    else: return f"{bytes_size/(1024**3):.2f} GB"

def format_duration(seconds):
    h, rem = divmod(seconds, 3600)
    m, s = divmod(rem, 60)
    return f"{h}h {m}m" if h > 0 else f"{m}m {s}s"

def format_speed(bps):
    if bps < 1024**2: return f"{bps/1024:.1f} KB/s"
    else: return f"{bps/(1024**2):.1f} MB/s"

def format_eta(seconds):
    if seconds < 60: return f"{int(seconds)} 秒"
    m, s = divmod(int(seconds), 60)
    h, m = divmod(m, 60)
    if h > 0: return f"{h}小时 {m}分钟"
    return f"{m}分钟 {s}秒"

def get_unique_filename(base_path, custom_name, ext):
    file_path = os.path.join(base_path, f"{custom_name}{ext}")
    counter = 1
    while os.path.exists(file_path):
        file_path = os.path.join(base_path, f"{custom_name}({counter}){ext}")
        counter += 1
    return file_path

def get_media_info_text(media):
    info_text = "📦 **视频信息**\n\n"
    if hasattr(media, 'document'):
        size = getattr(media.document, 'size', 0)
        info_text += f"💾 大小:`{format_size(size)}`\n"
        for attr in media.document.attributes:
            if isinstance(attr, DocumentAttributeVideo):
                info_text += f"⏱ 时长:`{format_duration(attr.duration)}`\n"
                info_text += f"🖥 分辨率:`{attr.w} × {attr.h}`\n"
    return info_text

def get_sys_status():
    try:
        with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f: temp = int(f.read().strip()) / 1000.0
    except: temp = 0.0
    try:
        with open('/proc/loadavg', 'r') as f: load = f.read().split()[:3]; load_str = f"{load[0]} {load[1]} {load[2]}"
    except: load_str = "未知"
    mem = subprocess.check_output(['free', '-m']).decode('utf-8').splitlines()[1].split()
    s_total, s_used, _ = shutil.disk_usage('/')
    try:
        t, u, f = shutil.disk_usage(BASE_DIR)
        ext_disk = f"已用: `{u/(1024**3):.1f}GB` / 总计: `{t/(1024**3):.1f}GB` ({round(u/t*100,1)}%)"
    except: ext_disk = "未检测到硬盘!"
    return (f"📊 **鲁班猫 (H618) 状态面板**\n\n"
            f"🌡️ **温度:** `{temp:.1f} °C`  ⚡ **负载:** `{load_str}`\n"
            f"🧠 **内存:** `{mem[2]}MB / {mem[1]}MB`\n"
            f"📟 **系统盘:** `{s_used/(1024**3):.1f}GB / {s_total/(1024**3):.1f}GB`\n"
            f"💾 **机械硬盘(HDD):**\n   {ext_disk}")

# 后台垃圾回收 (GC)
async def task_cleaner():
    while True:
        now = time.time()
        expired = [k for k, v in pending_tasks.items() if now - v['timestamp'] > 1800]
        for k in expired: del pending_tasks[k]
        await asyncio.sleep(300)

# 单线程下载消费者
async def download_worker():
    while True:
        task_data = await download_queue.get()
        await execute_download(task_data)
        download_queue.task_done()

# 1. 常用指令监听 (/history, /queue, /status)
@client.on(events.NewMessage(pattern='/status', chats=target_chat))
async def status_handler(event):
    await event.reply(get_sys_status())

@client.on(events.NewMessage(pattern='/history', chats=target_chat))
async def history_handler(event):
    if not download_history:
        await event.reply("📭 暂无下载记录。")
        return
    text = "📜 **最近 10 个下载**\n\n"
    for i, item in enumerate(reversed(download_history), 1):
        text += f"{i}. `{item}`\n"
    await event.reply(text)

@client.on(events.NewMessage(pattern='/queue', chats=target_chat))
async def queue_handler(event):
    text = "🔄 **当前下载与队列**\n\n"
    if current_download_task:
        text += f"▶️ **正在下载:**\n`{current_download_task.get('name', '未知')}`  [{current_download_task.get('progress', '0%')}]\n\n"
    else:
        text += "▶️ **正在下载:**\n暂无任务\n\n"
    
    text += "⏳ **等待队列:**\n"
    if download_queue.empty():
        text += "空闲中"
    else:
        for i, item in enumerate(download_queue._queue, 1):
            name = item.get("final_name_guess") or "未命名视频"
            text += f"{i}. `{name}`\n"
    await event.reply(text)

# 2. 捕获媒体资源
@client.on(events.NewMessage(chats=target_chat))
async def message_handler(event):
    if event.text and event.text.startswith('/'): return
            
    # 核心修复:认频道不认人
    if event.chat_id in active_rename_session: return
        
    if event.media:
        task_id = event.id
        pending_tasks[task_id] = {
            "media": event.media,
            "msg_obj": event.message,
            "timestamp": time.time()
        }
                
        info_ui = get_media_info_text(event.media)
                
        await event.reply(
            f"{info_ui}\n📥 **请选择保存分类:**",
            buttons=[
                [Button.inline("🎬 电影", f"dl_1_{task_id}"), Button.inline("📺 剧集", f"dl_2_{task_id}")],
                [Button.inline("📂 默认视频", f"dl_3_{task_id}"), Button.inline("❌ 取消任务", f"cancel_{task_id}")]
            ]
        )

# 3. 按钮回调处理
@client.on(events.CallbackQuery())
async def callback_handler(event):
    data = event.data.decode()
    chat_id = event.chat_id
        
    if data.startswith("cancel_"):
        task_id = int(data.split("_")[1])
        pending_tasks.pop(task_id, None)
        active_rename_session.pop(chat_id, None)
        await event.edit("🚫 **任务已取消并清理**。")
        return
        
    if data.startswith("dl_"):
        _, category_id, task_id_str = data.split("_")
        task_id = int(task_id_str)
                
        if task_id not in pending_tasks:
            await event.answer("❌ 任务已过期 (超30分钟) 或被清理!", alert=True)
            return
                    
        cat_name = DIRS[category_id]["name"]
        save_path = DIRS[category_id]["path"]
                
        pending_tasks[task_id]["category_id"] = category_id
        pending_tasks[task_id]["cat_name"] = cat_name
        pending_tasks[task_id]["save_path"] = save_path
                
        # 将任务绑定给当前频道
        active_rename_session[chat_id] = task_id
                
        await event.edit(
            f"🚀 **已选择:{cat_name}**\n\n"
            f"✍️ 请直接发送你要修改的【**文件名**】。\n"
            f"💡 *(直接回复数字 `0` 则保持原名)*"
        )

# 4. 捕获命名并入队
@client.on(events.NewMessage(chats=target_chat))
async def name_input_handler(event):
    chat_id = event.chat_id
    if chat_id not in active_rename_session: return
                
    task_id = active_rename_session.pop(chat_id)
    task = pending_tasks.pop(task_id, None)
                
    if not task:
        await event.reply("❌ 任务异常丢失。")
        return
                
    input_name = event.text.strip()
    custom_name = "".join([c for c in input_name if c not in r'\/:*?"<>|']) if input_name != "0" else None
    task["custom_name"] = custom_name
    task["final_name_guess"] = custom_name if custom_name else "默认原名任务"
                
    queue_size = download_queue.qsize()
    status_msg = await event.reply(f"⏳ **排队中...**\n前面还有 {queue_size} 个任务在等待。")
    task["status_msg"] = status_msg
                
    await download_queue.put(task)

# 5. 带有可视化进度条与 ETA 的核心下载引擎
async def execute_download(task):
    save_path = task["save_path"]
    cat_name = task["cat_name"]
    custom_name = task["custom_name"]
    status_msg = task["status_msg"]
                
    os.makedirs(save_path, exist_ok=True)
                
    progress_state = {
        "last_time": time.time(),
        "last_bytes": 0,
        "ui_update_time": 0
    }
    
    async def progress_callback(current, total):
        now = time.time()
        # 控制刷新频率为 3 秒
        if now - progress_state["ui_update_time"] > 3:
            diff_bytes = current - progress_state["last_bytes"]
            diff_time = now - progress_state["last_time"]
            speed_bps = diff_bytes / diff_time if diff_time > 0 else 0
                            
            # 安全处理总大小,防止死锁
            safe_total = total if total else (current + 1)
            percent = (current / safe_total) * 100
            eta_seconds = (safe_total - current) / speed_bps if speed_bps > 0 else 0
                            
            # 渲染进度块
            filled = int(percent / 10)
            bar = f"[{'█' * filled}{'░' * (10 - filled)}]"
                            
            current_download_task["progress"] = f"{percent:.1f}%"
                            
            text = (f"⬇️ **下载中... ({cat_name})**\n\n"
                    f"{bar} `{percent:.1f}%`\n\n"
                    f"📊 容量: `{format_size(current)} / {format_size(safe_total)}`\n"
                    f"🚀 速度: `{format_speed(speed_bps)}`\n"
                    f"⏳ 剩余: `{format_eta(eta_seconds)}`")
            try:
                await status_msg.edit(text)
            except Exception:
                pass
                            
            progress_state["last_time"] = now
            progress_state["last_bytes"] = current
            progress_state["ui_update_time"] = now

    try:
        ext = ".mp4"
        if hasattr(task["media"], 'document'):
            for attr in task["media"].document.attributes:
                if hasattr(attr, 'file_name') and attr.file_name:
                    ext = os.path.splitext(attr.file_name)[1]
                    break
                    
        if not custom_name:
            custom_name = "未命名视频"
            if hasattr(task["media"], 'document'): 
                for attr in task["media"].document.attributes:
                    if hasattr(attr, 'file_name') and attr.file_name:
                        custom_name = os.path.splitext(attr.file_name)[0]
                        break
                
        final_file_path = get_unique_filename(save_path, custom_name, ext)
        final_name = os.path.basename(final_file_path)
                
        current_download_task["name"] = final_name
        current_download_task["progress"] = "0%"
                
        await status_msg.edit(f"🚀 开始下载:`{final_name}`...")
                
        await client.download_media(task["media"], file=final_file_path, progress_callback=progress_callback)
                
        current_download_task.clear()
        download_history.append(final_name)
                
        final_size = os.path.getsize(final_file_path)
                
        finish_text = (f"🎉 **下载完成**\n\n"
                      f"📁 文件:\n`{final_name}`\n\n"
                      f"💾 大小:\n`{format_size(final_size)}`\n\n"
                      f"🏷 分类:\n`{cat_name}`")
        await status_msg.edit(finish_text)
                
    except Exception as e:
        current_download_task.clear()
        await status_msg.edit(f"❌ **下载失败**\n错误: {str(e)}")

print("机器人启动...")
loop = asyncio.get_event_loop()
loop.create_task(task_cleaner())
loop.create_task(download_worker())

# 携带 Bot Token 启动
client.start(bot_token=bot_token)
client.run_until_disconnected()

Docker

我的建议是全部在1panel上部署,这样方便查看和管理,而且操作也简单方便。下面是几个好用的:

freellmapi

https://github.com/tashfeenahmed/freellmapi这个是集成全网免费的ai,需要自己一个一个注册输入api,如果用得少不的话随便配置几个就行了,我主要是用来玩一些可以接入ai的游戏,纯娱乐,如果需要智商不高免费的ai的话挺值得搞一个的。下面是在docker部署的步骤:

1. 创建专属工作目录

为了避免权限错乱,在普通用户家目录下创建项目文件夹:

mkdir -p ~/freellmapi && cd ~/freellmapi
2. 生成 .env 配置文件

自动生成安全所需的 32 位随机 AES 密钥并写入配置文件:

ENCRYPTION_KEY="$(openssl rand -hex 32)"
printf "ENCRYPTION_KEY=%s\nPORT=3001\nHOST_BIND=0.0.0.0\n" "$ENCRYPTION_KEY" > .env
3.配置代理

修改 Docker 核心总控文件 /etc/docker/daemon.json:

sudo mkdir -p /etc/docker    

sudo cat << 'EOF' > /etc/docker/daemon.json
{
  "proxies": {
    "http-proxy": "http://127.0.0.1:7890",
    "https-proxy": "http://127.0.0.1:7890",
    "no-proxy": "localhost,127.0.0.1"
  }
}
EOF

重启 Docker 并拉取原生镜像

sudo systemctl restart docker
docker compose pull

执行完后恢复 Docker 全局网络

sudo cat << 'EOF' > /etc/docker/daemon.json
{}
EOF     

sudo systemctl restart docker
4.配置 docker-compose.yml

在 ~/freellmapi 目录下,创建并写入以下配置文件

cat << 'EOF' > docker-compose.yml
services:
  freellmapi:
    image: ghcr.io/tashfeenahmed/freellmapi:latest
    network_mode: "host"
    env_file:
      - .env
    environment:
      NODE_ENV: production
      PORT: 3001
      # 仅本容器内部请求会走 7890 代理,绝对不影响宿主机和其他应用
      HTTP_PROXY: http://127.0.0.1:7890
      HTTPS_PROXY: http://127.0.0.1:7890
      http_proxy: http://127.0.0.1:7890
      https_proxy: http://127.0.0.1:7890
      PROXY_URL: http://127.0.0.1:7890
    volumes:
      - freellmapi-data:/app/server/data
    restart: unless-stopped

volumes:
  freellmapi-data:
EOF
5.启动容器
docker compose up -d

查看是否成功

docker compose logs --tail=20

如果有

[proxy] Configured → http://127.0.0.1:7890
Server running on http://[::]:3001

代表成功。

6.启动

IP:3001

FRPC

这个也是内网穿透,但是和zerotier不一样的是这个是连接到我的阿里云服务器,有公网ip,可以直接访问。搞这个的原因是手机上zerotier是vpn服务,如果自己再开一个vpn使用国外工具的话两者不能共存(手机只支持开一个vpn)。直接在1panel的商店找到就行,然后按照模板改下配置文件配置ip地址和密码,在服务端也改一下就行了。路径是/opt/1panel/apps/frpc/.env

创建文件

登录1Panel ,进入文件。直接在 /opt 目录下新建一个名为 navidrome 的文件夹。在 /opt/navidrome 内部,继续创建两个子文件夹:data (用于存放系统数据)music 。点击 1Panel 左侧菜单的 “容器” -> “编排”。点击右上角的 “创建编排”。我安装了casaos,里面下载了音乐所以存储第一个挂载/date第二个挂载真正的音乐文件夹。

编排容器

编排名称:填写 navidrome,编辑模式:选择“文本”。

version: "3"
services:
  navidrome:
    image: deluan/navidrome:latest
    container_name: navidrome
    ports:
      - "4533:4533"
    restart: unless-stopped
    environment:
      ND_LOGLEVEL: "info"
      ND_SESSIONTIMEOUT: "24h"
      ND_BASEURL: ""
      ND_DEFAULTLANGUAGE: "zh-CN"
    volumes:
      - "/opt/navidrome/data:/data"
      - "/DATA/Media/Music:/music:ro"

点击确定即可

设置

进入4533端口,完成初始化设置,创建博客所需的歌单进入歌单复制浏览器后的那一串id。在插件设置里公网ip填http://192.168.1.6:4533(按自己的来),容器/内网服务地址:http://navidrome:4533,其他照填。歌单id填刚刚复制的那串。

改写

感觉原版的图标太丑了,又大遮挡视野,重写了一下,因为用了大量ai留着自用不提交了。 plugin-navidrome-0.1.0-SNAPSHOT.jar

抖音续火花脚本

项目https://github.com/2061360308/DouYinSparkFlow,由于抖音风控作者预制的方法无法使用,于是部署在服务器上,由于是arm架构还挺麻烦的不过家用宽带应该不会风控了。

适用环境:

系统架构:aarch64 / arm64(x86_64 同样适用) 操作系统:Ubuntu 22.04 / Debian 系 内存:≥ 3.8 GB 已能联网访问 creator.douyin.com

⚠️ 不要走 Docker、不要走系统 chromium-browser 包、不要走 snap chromium。这三个坑在本项目里都不通,原因见文末「附录:踩过的坑」。

0. 准备工作

0.1 拉取代码
cd ~
git clone https://github.com/12userzhou/DouYinSparkFlow.git douyinsparkflow
# 如果是你自己 fork 的仓库,把 URL 换成 fork 地址
cd douyinsparkflow
0.2 关闭 GitHub Action 定时

⚠️ 本项目默认带 3 个 GitHub Actions 定时工作流(schedule.yml / schedule_api.yml / schedule_dev.yml),每天都会自动跑一次。既然改用本地 crontab 部署,必须关掉 GitHub 这边的定时,否则会容易触发抖音风控导致 cookie 失效

方式 A:GitHub UI 禁用(推荐,30 秒搞定) 不需要任何 git 操作,直接在网页上点:

  1. 打开 https://github.com/<你的用户名>/DouYinSparkFlow/actions

  2. 左侧列表依次点这三个 workflow:

    • DouYin Spark Flow Schedule Run

    • 【api(测试)分支】DouYin Spark Flow Schedule Run

    • 【dev(测试)分支】DouYin Spark Flow Schedule Run

  3. 进每个 workflow 后,点右上角 ... → Disable workflow

禁用后定时和手动触发都会停止,本地 crontab 不受影响。做完这一步就够了,下面的方式 B 可以跳过。

方式 B:改 workflow 文件后 push(可选,需带 workflow scope 的 PAT) 把"已禁用定时"的状态固化进代码仓库,可以编辑三个 workflow 文件,把 schedule: 触发器注释掉:

on:
  workflow_dispatch: # 允许手动触发
  # 已改为本地 crontab 部署,禁用 GitHub Action 定时
  # schedule: # 定时任务
  #   - cron: "0 19 * * *"

然后提交并推送

git add .github/workflows/
git commit -m "ci: 禁用 GitHub Actions 定时触发"
git push origin main

⚠️ 注意:push 含 .github/workflows/ 的改动要求 PAT 带 workflow scope,否则会报 refusing to allow a Personal Access Token to create or update workflow ... without workflow scope。去 https://github.com/settings/tokens 勾上 workflow scope 重新生成即可。不想折腾 token 的话,直接用方式 A,效果一样。

1. 一键部署环境

仓库里有现成的 setup-native.sh,它会自动完成以下 5 步:

  1. 安装系统依赖(Python venv + Playwright 运行库 libnss3 / libatk / libgbm 等)

  2. 创建 Python 虚拟环境(用 --without-pip + get-pip.py 兜底,绕开 Ubuntu 22.04 上 venv 不完整的问题)

  3. 用阿里云镜像装 Python 依赖

  4. 用 Playwright 官方 CDN 下载 arm64 chromium headless_shell

  5. 创建 logs 目录

直接执行:

bash setup-native.sh

关键点:

  • chromium 必须用 PLAYWRIGHT_DOWNLOAD_HOST=https://playwright.azureedge.net 走官方 CDN,国内 npmmirror 镜像缺 arm64 headless_shell 包会 404。

  • venv 必须用 --without-pip,否则在部分 Ubuntu 22.04 上 venv 创建不完整(只有 python 软链,没有 activate / pip)。

  • 不要装系统的 chromium-browser 包,它在 arm64 上是个 wrapper 脚本会报错,而且 snap 版本受 GLIBC 限制跑不起来。

部署成功后,会看到类似输出:

==========================================
 部署完成!
==========================================

2. 创建 .env 配置文件

在项目根目录创建 .env,模板可参考 .env.example。下面是一份实测可用的最小配置示例:

# ===== 普通变量 =====
# 消息内容(不需要一言就直接写文字)
MESSAGE_TEMPLATE=早上好     

# 一言类型(MESSAGE_TEMPLATE 不含 [API] 时这个字段会被忽略)
HITOKOTO_TYPES=["文学","影视","诗词","哲学"]     

# 匹配模式:short_id = 按抖音号匹配(推荐,比 nickname 稳定)
MATCH_MODE=short_id     

BROWSER_TIMEOUT=120000
FRIEND_LIST_WAIT_TIME=2000
TASK_RETRY_TIMES=3
LOG_LEVEL=INFO     

# ===== 任务列表 =====
# username 是备注名(日志里用);unique_id 用于关联下面的 COOKIES_
# targets 里填好友的【抖音号 unique_id】或【纯数字 short_id】,两种都能匹配
TASKS=[{"username":"不偷井盖(吃香菜版)","unique_id":"u1","targets":["抖音号1","抖音号2"]}]     

# ===== Cookie =====
# 从浏览器开发者工具导出的 Cookie JSON 数组(数组里每个对象要有 name/value/domain/path)
COOKIES_U1=[{"name":"sessionid","value":"替换成真实值","domain":".douyin.com","path":"/"}, ...]

获取cookie原本文档里有。

3. 手动运行验证

cd ~/douyinsparkflow
source venv/bin/activate
python -u main.py 2>&1 | tee /tmp/run.log

看到这样的日志说明发送成功:

[browser] 使用 Playwright headless_shell: /home/cat/.cache/ms-playwright/chromium_headless_shell-1208/chrome-linux/headless_shell
INFO - 开始执行任务
INFO - 开始处理账号 不偷井盖(吃香菜版)
INFO - 监听到 user_detail 接口响应,user_list 共 15 条
INFO - 选中目标好友 槐序 (61905275090) 准备开始交互
INFO - 已选中好友 槐序,准备发送消息
INFO - 准备输入消息给好友 槐序:'早上好'
INFO - 输入框实际内容:'早上好'
INFO - 给好友 槐序 发送消息完成(输入框已清空)
...
INFO - 任务完成

每个好友都会经历「选中 → 输入 → 验证输入框内容 → 回车发送 → 清空确认」五个步骤。

4. 后续维护

4.1 设置每天 4 点定时执行
# 编辑 crontab
crontab -e

加入下面这行(注意把路径换成你的真实路径)

0 4 * * * cd /home/cat/douyinsparkflow && /home/cat/douyinsparkflow/venv/bin/python main.py >> /home/cat/douyinsparkflow/logs/cron.log 2>&1

保存退出。验证:

crontab -l
4.2 更新 Cookie(最常用的维护操作)

Cookie 过期后,重新从浏览器导出,然后二选一:

方式 A:用脚本(推荐)

cd ~/douyinsparkflow
bash update_cookie.sh
# 把新 cookie JSON 粘进去,Ctrl+D 结束

方式 B:手动编辑

nano ~/douyinsparkflow/.env
# 找到 COOKIES_U1= 那一行,整行替换

更新后立即跑一次验证:

source venv/bin/activate && python -u main.py 2>&1 | tee /tmp/run.log
4.3 改消息内容
cd ~/douyinsparkflow
# 例如改成"晚安"
sed -i 's|^MESSAGE_TEMPLATE=.*|MESSAGE_TEMPLATE=晚安|' .env
4.4 改好友列表
cd ~/douyinsparkflow
# 整行替换 TASKS=
# 注意 targets 里的抖音号结尾如果有 . 必须保留
sed -i 's|^TASKS=.*|TASKS=[{"username":"不偷井盖(吃香菜版)","unique_id":"u1","targets":["",""]}]|' .env

增加好友列表打开.env找到tasks

4.5 查看日志
# crontab 的运行日志
tail -f ~/douyinsparkflow/logs/cron.log     

# 旧的运行日志(每次手动运行会覆盖)
ls ~/douyinsparkflow/logs/

跑一次 python -u main.py,如果日志里反复出现

ERROR - 账号 xxx 第 1/3 次检测到登录/扫码页面,cookie 可能已失效
ERROR - 账号 xxx 第 2/3 次检测到登录/扫码页面,cookie 可能已失效
ERROR - 账号 xxx 第 3/3 次检测到登录/扫码页面,cookie 可能已失效

就是 Cookie 过期了,按 4.2 节更新即可。

5. 常见问题

Q1:ModuleNotFoundError: No module named 'dotenv'

没有激活 venv,或者 venv 没装好。

cd ~/douyinsparkflow
source venv/bin/activate
python -u main.py

Q2:chromium-headless-shell-linux-arm64.zip 404

下载源不对,必须用官方 CDN:

source venv/bin/activate
PLAYWRIGHT_DOWNLOAD_HOST=https://playwright.azureedge.net playwright install chromium

Q3:Missing X server

启用了完整 chromium 而不是 headless_shell。项目 core/browser.py 已经做了自动选择,确保 ~/.cache/ms-playwright/chromium_headless_shell-*/ 目录存在即可。

Q4:requires the chromium snap to be installed

误用了系统 chromium。卸载 snap chromium:

sudo snap remove chromium

Q5:只给第一个好友发了消息

检查 .env 里 TASKS 的 targets 数组是不是真的有多个元素,以及抖音号结尾的 . 有没有漏掉。

Q6:日志里出现 解析响应失败: Response.json: Target page, context or browser has been closed

无害噪音,浏览器关闭时残留接口响应触发的,已经做了静默处理,可以忽略

Q7:crontab 不执行

确认 crond 服务在跑:sudo systemctl status cron 确认 venv 路径对:ls /home/cat/douyinsparkflow/venv/bin/python 看日志:tail -f /home/cat/douyinsparkflow/logs/cron.log

Q8:GitHub Action 还在定时跑,和本地 crontab 重复发送

说明 workflow 没被禁用。最快的解决办法是去 GitHub UI 直接禁用(不需要 git 操作),见本文档「0.3 关闭 GitHub Action 定时 → 方式 A」。 如果走方式 B(改 workflow 文件 push),push 时报 refusing to allow a Personal Access Token to create or update workflow ... without workflow scope,说明你的 PAT 没 workflow scope,去 token 设置页勾上重新生成就行;不想折腾 token 的话直接用方式 A,效果一样。

附录:踩过的坑(仅供避雷,无需操作)

方案

失败原因

Docker 部署

Playwright 官方 chromium 无 arm64 预编译镜像,Docker build 在 arm64 上卡死

系统装 chromium-browser apt 包

Ubuntu 22.04 上是个 wrapper 脚本,会报 requires the chromium snap to be installed

snap chromium

1) 鲁班猫受限内核报 cannot attach cgroup;2) 真实二进制报 GLIBC_2.38 not found(Ubuntu 22.04 只有 2.35)

npmmirror 下载 Playwright chromium

缺 arm64 headless_shell 包,404

python3 -m venv venv 不带参数

Ubuntu 22.04 上 venv 创建不完整(只有 python 软链,没有 activate / pip)

用完整 chromium 跑 headless

报 Missing X server,必须用 headless_shell

最终的正确方案就是本教程第 1 节的 setup-native.sh:Playwright 官方 CDN 下载 arm64 headless_shell + --without-pip 创建 venv。

安全防护

使用了Fail2Ban(SSH 防爆破 + 实时告警) 和 rkhunter(挖矿 / 后门定期扫描)配合Server 酱消息推送。

Fail2Ban

安装
sudo apt update
sudo apt install -y fail2ban
配置 SSH 防护规则

新建本地配置文件(不要修改默认的jail.conf,系统升级会被覆盖):

粘贴以下配置:

[DEFAULT]
bantime = 3600    # 封禁时长:3600秒=1小时
findtime = 600    # 10分钟内统计失败次数
maxretry = 5      # 最多失败5次就封禁

[sshd]
enabled = true
port = ssh        # 如果你的SSH不是22端口,改成实际端口号
filter = sshd
logpath = /var/log/auth.log
maxretry = 5
action = %(action_)s
         wechat

触发封禁时自动发微信

sudo nano /etc/fail2ban/action.d/wechat.conf

粘贴内容,替换你的SendKey

[Definition]
actionban = curl -s "https://sctapi.ftqq.com/你的SendKey.send?title=SSH封禁告警&desp=IP <ip> 连续登录失败,已被封禁1小时" > /dev/null
actionunban = curl -s "https://sctapi.ftqq.com/你的SendKey.send?title=SSH解封通知&desp=IP <ip> 封禁到期已自动解封" > /dev/null

加执行权限

sudo chmod +x /etc/profile.d/login_alert.sh

设置开机自启

sudo systemctl enable --now fail2ban

常用管理命令

# 查看SSH防护状态、已封禁IP
sudo fail2ban-client status sshd
# 手动解封某个IP
sudo fail2ban-client set sshd unbanip 要解封的IP地址

rkhunter

安装

sudo apt install -y rkhunter

配置

初始化系统

sudo rkhunter --update
sudo rkhunter --propupd

配置每日自动扫描 + 微信告警

sudo nano /etc/cron.daily/rkhunter_scan.sh

粘贴内容

#!/bin/bash
# 执行静默扫描,只保留警告信息
rkhunter --check --skip-keypress --report-warnings-only > /tmp/rkhunter_result.log 2>/dev/null

# 有异常则推送微信
if [ -s /tmp/rkhunter_result.log ]; then
    content=$(cat /tmp/rkhunter_result.log | tr '\n' '<br>')
    curl -s "https://sctapi.ftqq.com/你的SendKey.send?title=安全扫描告警&desp=$content" > /dev/null
fi

给脚本加执行权限:

sudo chmod +x /etc/cron.daily/rkhunter_scan.sh

手动扫描命令

sudo rkhunter --check --skip-keypress --report-warnings-only \
  --disable suspscan,hidden_procs,deleted_procs \
  --exclude-dir=/var/lib/docker \
  --exclude-dir=/mnt \
  --exclude-dir=/media \
  --exclude-dir=/tmp/ShellCrash

优化规则写入配置文件

sudo tee -a /etc/rkhunter.conf << 'EOF'
# 排除大体积/外部挂载目录
EXCLUDE_DIR=/var/lib/docker
EXCLUDE_DIR=/mnt
EXCLUDE_DIR=/media
EXCLUDE_DIR=/tmp/ShellCrash

# 禁用耗时且低优先级的检测项
DISABLE_TESTS=suspscan hidden_procs deleted_procs
EOF

硬件

搞了一块ST7789 屏幕显示ip地址、温度和内存、cpu使用率但是感觉没必要拔了,还是分享一下吧毕竟不会搞硬件插错好多次以为写错了搞了好久。

40-Pin 物理接线图

ST7789 屏幕引脚

鲁班猫 A0 物理引脚号

鲁班猫 A0 系统功能 / 编号

状态及作用

VCC

Pin 1

3.3V

屏幕供电(绝不能接5V

GND

Pin 6

GND

供电地线

BL

Pin 17

3.3V

背光常亮(必须接 3.3V

SDA

Pin 19

SPI1_MOSI

SPI 数据线

SCL

Pin 23

SPI1_CLK

SPI 时钟线

DC

Pin 13

GPIO 72 (PC8)

数据/命令控制引脚

RST / RES

Pin 27

GPIO 75 (PC11)

屏幕硬件复位引脚

CS

Pin 37

SPI1_CS0 (PH5)

屏幕片选线

SDO

不接 (悬空)

-

屏幕无输出数据,不接

CSF

不接 (悬空)

-

字库芯片片选,不接

系统配置

环境配置文件路径:/boot/uEnv.txt

#26pin
overlays=h618-lubancat-spi1-overlay.dtbo
#格式:overlays=xxx.dtbo xxx.dtbo xxx.dtbo
#2选1:h618-lubancat-i2c4-overlay.dtbo h618-lubancat-uart2-overlay.dtbo
#2选1:h618-lubancat-i2c2-overlay.dtbo h618-lubancat-uart5-overlay.dtbo
#h618-lubancat-spi1-overlay.dtbo

在终端输入命令新建文件

nano st7789_pro.py

代码如下

import time
import spidev
import os
import socket
import shutil
import numpy as np
import subprocess
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont

# --- 原生 Sysfs GPIO 驱动器 ---
class SysfsGPIO:
    def init(self, pin_number):
        self.pin = str(pin_number)
        self.path = f"/sys/class/gpio/gpio{self.pin}"
        if not os.path.exists(self.path):
            try:
                with open("/sys/class/gpio/export", "w") as f:
                    f.write(self.pin)
                time.sleep(0.1)
            except Exception:
                pass
        with open(f"{self.path}/direction", "w") as f:
            f.write("out")
        self.val_file = open(f"{self.path}/value", "w")

    def set_value(self, value):
        self.val_file.write("1" if value else "0")
        self.val_file.flush()

    def release(self):
        self.val_file.close()
        try:
            with open("/sys/class/gpio/unexport", "w") as f:
                f.write(self.pin)
        except:
            pass

# --- ST7789 驱动类 ---
class ST7789_SafeDriver:
    def init(self, spi_bus=1, spi_device=0, dc_pin=72, res_pin=75):
        self.spi = spidev.SpiDev()
        self.spi.open(spi_bus, spi_device)
        self.spi.max_speed_hz = 40000000 
        self.spi.mode = 0
        self.dc = SysfsGPIO(dc_pin)
        self.res = SysfsGPIO(res_pin)
        self.width = 240
        self.height = 240
        self._init_display()

    def sendcmd(self, cmd):
        self.dc.set_value(0)
        self.spi.writebytes([cmd])

    def senddata(self, data):
        self.dc.set_value(1)
        if isinstance(data, int):
            self.spi.writebytes([data])
        else:
            self.spi.writebytes(data)

    def initdisplay(self):
        self.res.set_value(1)
        time.sleep(0.1)
        self.res.set_value(0)
        time.sleep(0.1)
        self.res.set_value(1)
        time.sleep(0.1)
        self._send_cmd(0x11)
        time.sleep(0.12)
        self._send_cmd(0x36)
        self._send_data(0x00)
        self._send_cmd(0x3A)
        self._send_data(0x05)
        self._send_cmd(0x21)
        self._send_cmd(0x13)
        self._send_cmd(0x29)

    def set_window(self, x0, y0, x1, y1):
        self._send_cmd(0x2A)
        self._send_data([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF])
        self._send_cmd(0x2B)
        self._send_data([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF])
        self._send_cmd(0x2C)

    def display_image(self, img):
        img = img.resize((self.width, self.height)).convert("RGB")
        r, g, b = img.split()
        r_arr = np.array(r, dtype=np.uint16) >> 3
        g_arr = np.array(g, dtype=np.uint16) >> 2
        b_arr = np.array(b, dtype=np.uint16) >> 3
        rgb565 = (r_arr << 11) | (g_arr << 5) | b_arr
        rgb565 = rgb565.byteswap()
        self.set_window(0, 0, self.width - 1, self.height - 1)
        self.dc.set_value(1)
        self.spi.writebytes2(rgb565.tobytes())

    def close(self):
        try:
            self.spi.close()
            self.dc.release()
            self.res.release()
        except:
            pass

# --- 数据获取函数 ---
def get_ip_address():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80)) 
        ip = s.getsockname()[0]
        s.close()
        return ip
    except:
        return "Offline"

def get_cpu_temp():
    try:
        with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
            return round(float(f.read()) / 1000.0, 1)
    except:
        return 0.0

def get_ram_usage():
    try:
        out = subprocess.check_output(['free', '-m']).decode('utf-8').splitlines()[1]
        parts = out.split()
        return int(parts[2]), int(parts[1]), round((int(parts[2]) / int(parts[1])) * 100, 1)
    except:
        return 0, 0, 0.0

def get_disk_usage():
    try:
        total, used, free = shutil.disk_usage("/")
        gb = 2**30
        return round(used/gb, 1), round(total/gb, 1), int((used/total)*100)
    except:
        return 0.0, 0.0, 0

if __name__ == "__main__":
    disp = None
    try:
        disp = ST7789_SafeDriver()
                
        # 加载系统高清字体
        font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
        try:
            font_title = ImageFont.truetype(font_path, 18)
            font_main = ImageFont.truetype(font_path, 14)
            font_small = ImageFont.truetype(font_path, 12)
        except:
            font_title = font_main = font_small = ImageFont.load_default()

        while True:
            image = Image.new("RGB", (240, 240), "black")
            draw = ImageDraw.Draw(image)
                            
            # 获取数据
            cur_time = datetime.now().strftime("%H:%M:%S")
            ip_addr = get_ip_address()
            cpu_temp = get_cpu_temp()
            ram_used, ram_total, ram_pct = get_ram_usage()
            disk_used, disk_total, disk_pct = get_disk_usage()
                            
            # UI 绘制
            draw.rectangle((2, 2, 237, 237), outline="#3399FF", width=2)
                            
            # 顶部栏 (时间 + IP)
            draw.rectangle((2, 2, 237, 30), fill="#3399FF")
            draw.text((10, 5), cur_time, fill="white", font=font_title)
            draw.text((100, 7), ip_addr, fill="white", font=font_main)
                            
            # CPU 温度
            t_color = "#FF3333" if cpu_temp > 65 else "#00FF00"
            draw.text((15, 45), f"CPU Temp: {cpu_temp} °C", fill=t_color, font=font_title)
                            
            # RAM 内存条
            draw.text((15, 85), f"RAM: {ram_used}M / {ram_total}M", fill="#FFFF00", font=font_main)
            draw.rectangle((15, 105, 220, 120), outline="white")
            draw.rectangle((15, 105, 15 + int(205 * (ram_pct / 100)), 120), fill="#00FFFF")
            draw.text((105, 106), f"{ram_pct}%", fill="black", font=font_small)
                            
            # 磁盘存储条
            draw.text((15, 140), f"SD Card: {disk_used}G / {disk_total}G", fill="#FF99FF", font=font_main)
            draw.rectangle((15, 160, 220, 175), outline="white")
            draw.rectangle((15, 160, 15 + int(205 * (disk_pct / 100)), 175), fill="#FF66FF")
            draw.text((105, 161), f"{disk_pct}%", fill="black", font=font_small)
                            
            # 底部标识
            draw.line((10, 205, 230, 205), fill="#555555")
            draw.text((45, 212), "LuBanCat System Pro", fill="#888888", font=font_small)
                            
            disp.display_image(image)
            time.sleep(2)
                        
    except KeyboardInterrupt:
        pass
    finally:
        if disp:
            # 退出时刷黑屏幕保护硬件
            disp.display_image(Image.new("RGB", (240, 240), "black"))
            disp.close()

操作

开灯运行监控:

sudo systemctl start st7789_lcd.service 

关灯黑屏休息:

sudo systemctl stop st7789_lcd.service  

允许开机自动亮起:

sudo systemctl enable st7789_lcd.service  

禁止开机自动亮起:

sudo systemctl disable st7789_lcd.service  

强行杀死残留进程(如果stop后没黑屏):

原创

开发板开发笔记

本文链接: 开发板开发笔记

本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

评论交流

文章目录