15 Commits

Author SHA1 Message Date
2331d0c73f fix(tts): 修复 TTS 播放卡死问题并增强日志
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- 添加 SoundWithContext 方法,使用请求 context 而非全局 context
- 修复 TTS 使用服务器全局 context 导致无法取消的问题
- 添加详细的诊断日志(解码、播放、TTS 合成各阶段)
- 检测并记录 TTS 合成数据为空的情况

修复前 TTS 播放使用全局 context,当播放卡住时无法通过超时
或取消机制中断,导致后续任务永远无法执行。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:07:39 +08:00
5ee8e15965 fix(audio): 修复音频播放死循环并增强错误日志
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- 修复 PlayWav 和 PlayMP3 在 context 取消时的死循环 bug
- 添加 WAV/MP3 解码失败的错误日志
- 添加 TTS 播放开始/完成的日志,便于排查问题

修复前 context 取消会导致无限循环,阻塞后续任务执行。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:34:27 +08:00
ae5f998d88 ci: 优化构建配置
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- amd64 原生构建移除冗余的 GOOS/GOARCH/CGO/CC 设置
- 删除不需要的 gcc-x86-64-linux-gnu 安装
- 添加构建产物列表便于调试
- 修复 release 步骤的 YAML 缩进

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:35:55 +08:00
6cf28217a4 ci: 增强多架构构建支持
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
- 启用 arm64 多架构和安装 libasound2/libvlc 开发库
- 设置 PKG_CONFIG_PATH 确保交叉编译时依赖正确链接
- 分别配置 amd64 和 arm64 的 pkg-config 路径

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:22:07 +08:00
e93e99480b ci: 修复 amd64 交叉编译器变量名
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
CC 应从 x86-64-linux-gnu-gcc 改为 x86_64-linux-gnu-gcc

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:13:24 +08:00
619e919fa0 ci: 优化构建速度,添加 Go 代理和 APT 镜像
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
- 设置 GOPROXY 为 goproxy.cn 加速依赖下载
- 替换 Debian 源为阿里云镜像加速 apt 安装

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:07:29 +08:00
809f123854 ci: 升级构建环境至 Go 1.24
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 17:54:23 +08:00
46961040b3 ci: 添加 Woodpecker CI 配置并更新 .gitignore
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
- 新增 tag 触发的 amd64/arm64 多架构构建与发布流水线
- .gitignore 忽略 .qwen 目录
- 修复 .gitignore 文件末尾换行符

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 17:48:38 +08:00
4349413887 refactor(utils): 禁用屏幕开关并优化 xset 查找逻辑
- 注释掉所有 BlankOpen/BlankClose 调用,启动不再关屏
- 将 xset 路径查找改为 init + sync.Once 缓存,避免重复执行
- 清理未使用的 utils 导入

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 16:52:33 +08:00
994023553d chore(deps): 清理未使用的 go 依赖
移除旧版本的 aliyun-sdk、fetchup、x/exp、x/net、x/sys、x/tools 等

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 16:52:20 +08:00
dd0c7b8feb chore(config): 更新 wushan-5 点位配置
- 点位从 3 调整为 5
- 更新 MQTT 服务器地址和客户端 ID
- 更新游戏服务器 host 和端口
- 修复文件末尾换行符

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 16:52:03 +08:00
2d5d3919e2 降级日志 2025-09-26 16:48:11 +08:00
ac0a338b76 脚本优化 2025-09-25 18:07:59 +08:00
24b2b6c199 增加 fontconfig 包 2025-09-25 11:48:51 +08:00
a314a1a0d8 增加初始化脚本 2025-07-11 17:41:36 +08:00
16 changed files with 771 additions and 50 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
/logs
/.idea
/.vscode
/.qwen
*.mp3
game-driver*
game-driver*

43
.woodpecker.yml Normal file
View File

@@ -0,0 +1,43 @@
when:
- event: tag
clone:
git:
image: docker.m.daocloud.io/woodpeckerci/plugin-git
settings:
depth: 1
steps:
# 构建多架构二进制文件
build:
image: docker.m.daocloud.io/golang:1.24-trixie
environment:
GOPROXY: https://goproxy.cn
commands:
- sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources
# 启用多架构支持并安装交叉编译工具
- dpkg --add-architecture arm64
- apt-get update
- apt-get install -y gcc-aarch64-linux-gnu pkg-config
- apt-get install -y libasound2-dev libvlc-dev
- apt-get install -y libasound2-dev:arm64 libvlc-dev:arm64
- mkdir -p release
# 构建 amd64 (native)
- PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig go build -ldflags="-w -s" -o release/game-driver-linux-amd64 .
# 构建 arm64 (cross-compile)
- PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc go build -ldflags="-w -s" -o release/game-driver-linux-arm64 .
- ls -lh release/
# 发布构建产物(可选)
release:
image: docker.m.daocloud.io/woodpeckerci/plugin-release
settings:
base-url: https://gitea.tides.top
title: ${CI_COMMIT_TAG}
api-key:
from_secret: gitea_token
files:
- release/game-driver-linux-amd64
- release/game-driver-linux-arm64
depends_on:
- build

View File

@@ -1,5 +1,5 @@
location: wushan # 项目名称
point: 3 # 点位
point: 5 # 点位
relay:
maxTimeout: 60 # 单位 s必须大于 0
standbyCache: # 待机缓存文件路径
@@ -17,9 +17,9 @@ log:
maxAge: 30
compress: true
mqtt:
url: mqtt://wushan-mqtt.chaoshengshuzi.com:1883
clientID: wushan-3
password: wushan@1013
url: mqtt://mqtt.wxsxlj.com:1883
clientID: wushan-5
password:
aliyun:
accessKeyID:
accessKeySecret:
@@ -31,8 +31,8 @@ aliyun:
# 激光秀点位 osc 参数
game:
host: 192.168.0.167
port: 3033
host: 192.168.1.191
port: 8000
# 待机投影仪控制参数
#wait:
@@ -65,4 +65,4 @@ game:
# empty: 24
# push: 10
# reset: 9
# pull: 11
# pull: 11

12
go.sum
View File

@@ -5,8 +5,6 @@ github.com/adrg/libvlc-go/v3 v3.1.6 h1:Cm22w6xNMDdzYCW8koHgAvjonYm4xbPP5TrlVTtMd
github.com/adrg/libvlc-go/v3 v3.1.6/go.mod h1:xJK0YD8cyMDejnrTFQinStE6RYCV1nlfS8KmqTpszSc=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1376/go.mod h1:9CMdKNL3ynIGPpfTcdwTvIm8SGuAZYYC4jFVSSvE1YQ=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.92 h1:qespx4b6EexlXkvQUow9x0v1GnWUJYGU5FWYw3a4Wlg=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.92/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.93 h1:yHRWq/QmBJ3lC15zy1A1+TkvcAN+6dr1bgHsFghKvmk=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.93/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/aliyun/alibabacloud-nls-go-sdk v1.1.1 h1:LjItoNZuu5xHlsByFo+kr3nGa4LRIESCGWhfurayxBg=
@@ -151,8 +149,6 @@ github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgX
github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8=
github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU=
github.com/ysmood/fetchup v0.2.4 h1:2kfWr/UrdiHg4KYRrxL2Jcrqx4DZYD+OtWu7WPBZl5o=
github.com/ysmood/fetchup v0.2.4/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A=
github.com/ysmood/fetchup v0.3.0 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8=
github.com/ysmood/fetchup v0.3.0/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
@@ -183,8 +179,6 @@ golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
@@ -195,8 +189,6 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -204,13 +196,9 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

602
init-device.py Executable file
View File

@@ -0,0 +1,602 @@
#!/usr/bin/env python3
"""
设备初始化自动化脚本
根据 init_device.md 文档自动执行设备初始化步骤
"""
import subprocess
import sys
import os
import argparse
import tempfile
import re
from pathlib import Path
from typing import Optional, List
from dataclasses import dataclass
from contextlib import contextmanager
@dataclass
class InitConfig:
"""初始化配置"""
install_chromium: bool = False
username: Optional[str] = None
dry_run: bool = False
class Logger:
"""优雅的日志输出"""
@staticmethod
def section(title: str):
"""输出章节标题"""
print(f"\n{'='*60}")
print(f" {title}")
print(f"{'='*60}")
@staticmethod
def step(description: str):
"""输出步骤说明"""
print(f"\n🔧 {description}")
@staticmethod
def success(message: str):
"""输出成功信息"""
print(f"{message}")
@staticmethod
def warning(message: str):
"""输出警告信息"""
print(f"⚠️ {message}")
@staticmethod
def error(message: str):
"""输出错误信息"""
print(f"{message}")
@staticmethod
def info(message: str):
"""输出信息"""
print(f" {message}")
class CommandRunner:
"""命令执行器"""
def __init__(self, dry_run: bool = False):
self.dry_run = dry_run
def _execute_command(self, command: str) -> subprocess.CompletedProcess:
"""执行命令的核心逻辑"""
with subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
) as process:
output_lines = []
# 实时读取并显示输出
for line in iter(process.stdout.readline, ''):
print(line.rstrip())
output_lines.append(line)
# 等待进程完成
process.wait()
return subprocess.CompletedProcess(
command,
process.returncode,
''.join(output_lines),
""
)
def run(self, command: str, description: str = "", check: bool = True) -> subprocess.CompletedProcess:
"""执行命令"""
Logger.step(description or command)
if self.dry_run:
Logger.info(f"[预览模式] 命令: {command}")
return subprocess.CompletedProcess(command, 0, "", "")
try:
result = self._execute_command(command)
if check and result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, command, result.stdout)
Logger.success("执行成功")
return result
except subprocess.CalledProcessError as e:
Logger.error(f"命令执行失败 (退出码: {e.returncode})")
if e.stderr:
print(f"错误详情: {e.stderr.strip()}")
if check:
sys.exit(1)
except Exception as e:
Logger.error(f"执行异常: {e}")
if check:
sys.exit(1)
@contextmanager
def temp_file_with_content(content: str, suffix: str = ""):
"""创建临时文件上下文管理器"""
with tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) as f:
f.write(content)
temp_path = f.name
try:
yield temp_path
finally:
Path(temp_path).unlink(missing_ok=True)
class DeviceInitializer:
"""设备初始化器"""
# 常量定义
SECTION_OPTIMIZE_BOOT = "优化系统启动时间"
SECTION_UPDATE_SYSTEM = "更新系统包列表"
SECTION_INSTALL_BASIC = "安装基础依赖"
SECTION_ADD_REPOS = "添加第三方软件源"
SECTION_INSTALL_PACKAGES = "安装主要软件包"
SECTION_CONFIGURE_SYSTEM = "配置系统设置"
SECTION_AUTO_STARTX = "配置图形界面自动启动"
SECTION_AUTO_LOGIN = "配置自动登录"
SECTION_CONFIGURE_I3 = "配置 i3 窗口管理器"
SECTION_MANUAL_STEPS = "手动配置步骤"
SECTION_COMPLETE = "初始化完成"
def __init__(self, config: InitConfig):
self.config = config
# 获取实际用户名sudo 下运行时从 SUDO_USER 获取)
self.username = config.username or os.getenv('SUDO_USER') or os.getenv('USER')
self.user_home = Path(f"/home/{self.username}")
self.runner = CommandRunner(config.dry_run)
# 软件包配置
self.base_packages = [
"fontconfig",
"fonts-noto-cjk",
"fonts-noto-color-emoji",
"unclutter",
"xorg",
"i3-wm",
"libvlc-dev",
"vlc-plugin-base",
"vlc-plugin-video-output",
"libasound2-dev",
"alsa-utils",
"trzsz",
"wireguard",
"wireguard-tools"
]
# 基础软件源(始终需要)
self.repositories = [
"ppa:trzsz/ppa"
]
# Chromium 仅在需要时添加的源
self.chromium_repo = "ppa:xtradeb/apps"
def _edit_systemd_service(self, service_name: str, override_content: str, description: str):
"""兼容地编辑 systemd 服务配置"""
# 直接创建 override 目录和文件,兼容所有 systemctl 版本
override_dir = f"/etc/systemd/system/{service_name}.d"
override_file = f"{override_dir}/override.conf"
# 创建目录
self.runner.run(f"sudo mkdir -p {override_dir}", f"创建服务 override 目录: {override_dir}")
# 写入配置文件
with temp_file_with_content(override_content) as temp_file:
self.runner.run(f"sudo cp {temp_file} {override_file}", f"创建服务配置文件: {override_file}")
# 重新加载 systemd 配置
self.runner.run("sudo systemctl daemon-reload", "重新加载 systemd 配置")
Logger.success(f"{description} - 已完成")
def optimize_boot_time(self):
"""优化 Ubuntu 24 开机时间"""
Logger.section(self.SECTION_OPTIMIZE_BOOT)
override_content = "[Service]\nTimeoutStartSec=2sec\n"
self._edit_systemd_service(
"systemd-networkd-wait-online.service",
override_content,
"配置网络等待服务超时时间为2秒"
)
def update_system(self):
"""更新系统包"""
Logger.section(self.SECTION_UPDATE_SYSTEM)
self.runner.run("sudo apt-get update", "刷新软件包索引")
def install_basic_packages(self):
"""安装基础包"""
Logger.section(self.SECTION_INSTALL_BASIC)
basic_packages = ["curl", "gpg"]
packages_str = " ".join(basic_packages)
self.runner.run(f"sudo apt-get install -y {packages_str}", f"安装基础包: {', '.join(basic_packages)}")
def add_repositories(self):
"""添加软件源"""
Logger.section(self.SECTION_ADD_REPOS)
# 基础源始终添加Chromium 源仅在需要安装 Chromium 时添加
repos = list(self.repositories)
if self.config.install_chromium:
repos.append(self.chromium_repo)
Logger.info("已选择安装 Chromium将添加其 PPA 源")
else:
Logger.info("未选择安装 Chromium跳过添加 xtradeb/apps PPA")
for repo in repos:
self.runner.run(f"sudo add-apt-repository -y {repo}", f"添加软件源: {repo}")
self.runner.run("sudo apt-get update", "更新软件包索引")
def install_packages(self):
"""安装主要软件包"""
Logger.section(self.SECTION_INSTALL_PACKAGES)
packages = self.base_packages.copy()
if self.config.install_chromium:
packages.insert(0, "ungoogled-chromium")
Logger.info("已包含 ungoogled-chromium")
packages_str = " ".join(packages)
package_list = ", ".join(packages)
# 安装软件包,实时显示进度
self.runner.run(f"sudo apt-get install -y {packages_str}", f"安装软件包: {package_list}")
def configure_system(self):
"""配置系统设置"""
Logger.section(self.SECTION_CONFIGURE_SYSTEM)
# 设置时区
self.runner.run("sudo timedatectl set-timezone Asia/Shanghai", "设置时区为上海")
# 添加用户到相关组
groups = "audio,video,dialout"
self.runner.run(
f"sudo usermod -aG {groups} {self.username}",
f"将用户 {self.username} 添加到 {groups}"
)
def setup_auto_startx(self):
"""设置自动启动 Xorg"""
Logger.section(self.SECTION_AUTO_STARTX)
bashrc_path = self.user_home / ".bashrc"
startx_config = """
# 自动启动 Xorg 和窗口管理器
if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then
startx
fi
"""
if self.config.dry_run:
Logger.step("配置自动启动 Xorg")
Logger.info(f"[预览模式] 将添加配置到 {bashrc_path}")
return
# 检查是否已存在配置
if bashrc_path.exists():
try:
content = bashrc_path.read_text(encoding='utf-8')
if 'startx' in content and 'tty1' in content:
Logger.warning("自动启动 Xorg 配置已存在,跳过")
return
except Exception as e:
Logger.warning(f"读取 .bashrc 文件失败: {e}")
# 添加配置
try:
with open(bashrc_path, 'a', encoding='utf-8') as f:
f.write(startx_config)
# 设置文件所有权为实际用户
self.runner.run(f"sudo chown {self.username}:{self.username} {bashrc_path}",
f"设置 .bashrc 所有权为 {self.username}")
Logger.success(f"已配置自动启动 Xorg: {bashrc_path}")
except Exception as e:
Logger.error(f"配置自动启动 Xorg 失败: {e}")
def setup_auto_login(self):
"""设置自动登录"""
Logger.section(self.SECTION_AUTO_LOGIN)
override_content = f"""[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin {self.username} --noclear %I $TERM
"""
self._edit_systemd_service(
"getty@tty1.service",
override_content,
f"配置用户 {self.username} 自动登录"
)
def configure_i3(self):
"""配置 i3 窗口管理器"""
Logger.section(self.SECTION_CONFIGURE_I3)
i3_config_dir = self.user_home / ".config" / "i3"
i3_config_path = i3_config_dir / "config"
if self.config.dry_run:
Logger.step("配置 i3 窗口管理器")
Logger.info(f"[预览模式] 将配置 i3 配置文件: {i3_config_path}")
return
# 创建 i3 配置目录
i3_config_dir.mkdir(parents=True, exist_ok=True)
# 检查是否已有 i3 配置文件
if not i3_config_path.exists():
Logger.info("i3 配置文件不存在,从系统默认配置复制")
self._copy_system_i3_config(i3_config_path)
else:
Logger.info("i3 配置文件已存在,直接更新配置")
# 无论是新生成还是已存在,都更新配置
self._update_i3_config(i3_config_path)
# 设置配置文件所有权为实际用户
self.runner.run(f"sudo chown -R {self.username}:{self.username} {i3_config_dir}", f"设置 i3 配置目录所有权为 {self.username}")
Logger.success(f"已配置 i3 窗口管理器: {i3_config_path}")
def _copy_system_i3_config(self, config_path: Path):
"""从系统默认配置复制 i3 配置文件"""
Logger.step("从系统默认配置复制 i3 配置文件")
system_config = "/etc/i3/config"
self.runner.run(f"cp {system_config} {config_path}", f"复制系统默认 i3 配置到 {config_path}")
# 移除 i3-config-wizard 指令
try:
content = config_path.read_text(encoding='utf-8')
if 'i3-config-wizard' in content:
# 移除包含 i3-config-wizard 的行
lines = content.splitlines()
filtered_lines = [line for line in lines if 'i3-config-wizard' not in line]
with open(config_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(filtered_lines) + '\n')
Logger.info("已移除系统配置中的 i3-config-wizard 指令")
except Exception as e:
Logger.warning(f"移除 i3-config-wizard 指令失败: {e}")
Logger.success("已从系统默认配置复制 i3 配置文件")
def _update_i3_config(self, config_path: Path):
"""更新现有 i3 配置文件"""
Logger.step("更新现有 i3 配置文件")
try:
content = config_path.read_text(encoding='utf-8')
except Exception as e:
Logger.error(f"读取 i3 配置文件失败: {e}")
return
# 添加启动配置项
startup_configs = [
"exec --no-startup-id unclutter -root # 隐藏鼠标",
"exec --no-startup-id xset dpms 0 0 0 # 关闭屏幕自动关闭",
"exec --no-startup-id xset s off # 关闭屏幕保护"
]
config_modified = False
for config_line in startup_configs:
if config_line not in content:
content += f"\n{config_line}\n"
Logger.info(f"添加配置: {config_line}")
config_modified = True
# 移除 i3bar 配置块
if "bar {" in content:
# 使用正则表达式直接移除整个 bar 配置块
bar_pattern = r'bar\s*\{[^}]*\}'
new_content = re.sub(bar_pattern, '', content, flags=re.DOTALL)
if new_content != content:
content = new_content
Logger.info("已移除 i3bar 状态栏配置")
config_modified = True
# 只在有修改时才写回文件
if config_modified:
try:
with open(config_path, 'w', encoding='utf-8') as f:
f.write(content)
Logger.success("i3 配置文件更新完成")
except Exception as e:
Logger.error(f"写入 i3 配置文件失败: {e}")
else:
Logger.info("i3 配置无需修改")
def show_manual_steps(self):
"""显示需要手动完成的步骤"""
Logger.section(self.SECTION_MANUAL_STEPS)
Logger.info("请手动完成以下配置步骤:")
print("\n📋 WireGuard 配置:")
print(" 1. 从服务器获取 WireGuard 配置文件")
print(" 2. 保存到 /etc/wireguard/wg0.conf")
print(" 3. 启用并启动 WireGuard:")
print(" sudo systemctl enable wg-quick@wg0")
print(" sudo systemctl start wg-quick@wg0")
print("\n📋 系统音量配置:")
print(" 1. 运行 alsamixer 进入音量控制界面")
print(" 2. 调节 master 音量到合适级别")
print(" 3. 按 ESC 退出")
print(" 4. 运行 sudo alsactl store 保存音量设置")
print("\n📋 i3 窗口管理器:")
print(" 1. 重启系统后会自动进入 i3")
print(" 2. 脚本已自动配置必要的设置(隐藏鼠标、禁用屏保等)")
print(" 3. 可以根据需要进一步自定义 ~/.config/i3/config")
def run_initialization(self):
"""运行完整的初始化流程"""
Logger.section("设备初始化开始")
Logger.info(f"目标用户: {self.username}")
Logger.info(f"用户主目录: {self.user_home}")
Logger.info(f"安装 Chromium: {'' if self.config.install_chromium else ''}")
Logger.info(f"预览模式: {'' if self.config.dry_run else ''}")
# 初始化步骤配置
initialization_steps = [
("优化系统启动", self.optimize_boot_time),
("更新系统包", self.update_system),
("安装基础依赖", self.install_basic_packages),
("添加软件源", self.add_repositories),
("安装主要软件", self.install_packages),
("配置系统设置", self.configure_system),
("配置自动启动X", self.setup_auto_startx),
("配置自动登录", self.setup_auto_login),
("配置i3窗口管理器", self.configure_i3),
]
try:
for step_name, step_func in initialization_steps:
Logger.info(f"正在执行: {step_name}")
step_func()
self.show_manual_steps()
Logger.section(self.SECTION_COMPLETE)
Logger.success("🎉 所有自动化步骤已完成")
Logger.warning("⚠️ 请重启系统以使所有更改生效")
Logger.info("💡 重启后请完成 WireGuard 和音量的手动配置")
except KeyboardInterrupt:
Logger.error("❌ 用户中断了初始化过程")
sys.exit(1)
except Exception as e:
Logger.error(f"❌ 初始化过程中发生错误: {e}")
sys.exit(1)
class SystemValidator:
"""系统环境验证器"""
@staticmethod
def validate_environment():
"""验证运行环境"""
Logger.section("验证系统环境")
# 检查是否通过 sudo 运行
if os.geteuid() != 0:
Logger.error("此脚本需要使用 sudo 运行")
Logger.info("请使用: sudo python3 init-device.py")
sys.exit(1)
# 检查是否有原始用户信息
if not os.getenv('SUDO_USER'):
Logger.error("请不要直接以 root 用户运行此脚本")
Logger.info("请使用普通用户通过 sudo 运行: sudo python3 init-device.py")
sys.exit(1)
Logger.info(f"检测到通过 sudo 运行脚本,原用户: {os.getenv('SUDO_USER')}")
Logger.success("系统环境验证通过")
def create_argument_parser() -> argparse.ArgumentParser:
"""创建命令行参数解析器"""
parser = argparse.ArgumentParser(
description="设备初始化自动化脚本",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
%(prog)s # 基础安装
%(prog)s --chromium # 包含 Chromium 的完整安装
%(prog)s --dry-run # 预览模式,不实际执行
%(prog)s --username user1 # 指定用户名
"""
)
parser.add_argument(
"--chromium",
action="store_true",
help="安装 ungoogled-chromium 浏览器"
)
parser.add_argument(
"--username",
type=str,
help="指定用户名(默认使用当前用户)"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="预览模式:仅显示将要执行的操作,不实际执行"
)
parser.add_argument(
"--version",
action="version",
version="%(prog)s 1.0.0"
)
return parser
def show_preview(config: InitConfig):
"""显示预览信息"""
Logger.section("预览模式")
steps = [
DeviceInitializer.SECTION_OPTIMIZE_BOOT,
DeviceInitializer.SECTION_UPDATE_SYSTEM,
f"{DeviceInitializer.SECTION_INSTALL_BASIC} (curl, gpg)",
DeviceInitializer.SECTION_ADD_REPOS,
f"{DeviceInitializer.SECTION_INSTALL_PACKAGES}" + (" (包含 ungoogled-chromium)" if config.install_chromium else ""),
f"{DeviceInitializer.SECTION_CONFIGURE_SYSTEM} (时区、用户组)",
DeviceInitializer.SECTION_AUTO_STARTX,
DeviceInitializer.SECTION_AUTO_LOGIN,
DeviceInitializer.SECTION_CONFIGURE_I3,
"显示手动配置步骤"
]
Logger.info("将要执行的操作步骤:")
for i, step in enumerate(steps, 1):
print(f" {i:2d}. {step}")
Logger.warning("这是预览模式,不会实际执行任何操作")
def main():
"""主函数"""
parser = create_argument_parser()
args = parser.parse_args()
# 创建配置
config = InitConfig(
install_chromium=args.chromium,
username=args.username,
dry_run=args.dry_run
)
# 预览模式
if config.dry_run:
show_preview(config)
return
# 验证环境
SystemValidator.validate_environment()
# 运行初始化
initializer = DeviceInitializer(config)
initializer.run_initialization()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
Logger.error("用户中断操作")
sys.exit(1)
except Exception as e:
Logger.error(f"程序异常退出: {e}")
sys.exit(1)

View File

@@ -2,7 +2,10 @@
```bash
# 在 systemd-networkd-wait-online.service Service 加入 TimeoutStartSec=2sec
sudo vim /etc/systemd/system/network-online.target.wants/systemd-networkd-wait-online.service
sudo EDITOR=vim systemctl edit systemd-networkd-wait-online.service
# 在打开的编辑器中添加:
# [Service]
# TimeoutStartSec=2sec
```
### 初始化设备
@@ -10,9 +13,9 @@ sudo vim /etc/systemd/system/network-online.target.wants/systemd-networkd-wait-o
```bash
sudo apt update
sudo apt install curl gpg
sudo add-apt-repository ppa:xtradeb/apps
sudo add-apt-repository ppa:xtradeb/apps # 不安装 ungoogled-chromium 时,不要添加。可能与系统源的库冲突
sudo add-apt-repository ppa:trzsz/ppa
sudo apt install -y ungoogled-chromium fonts-noto-cjk fonts-noto-color-emoji unclutter xorg i3-wm libvlc-dev libasound2-dev alsa-utils trzsz wireguard wireguard-tools
sudo apt install -y ungoogled-chromium fontconfig fonts-noto-cjk fonts-noto-color-emoji unclutter xorg i3-wm libvlc-dev vlc-plugin-base vlc-plugin-video-output libasound2-dev alsa-utils trzsz wireguard wireguard-tools
sudo timedatectl set-timezone Asia/Shanghai
sudo usermod -aG audio,video,dialout $USER
```
@@ -48,10 +51,14 @@ fi
### 自动登录
编辑 `/etc/systemd/system/getty.target.wants/getty@tty1.service` 文件,将 `ExecStart` 行修改为
使用 systemctl edit 修改 getty@tty1 服务
```bash
ExecStart=-/sbin/agetty --autologin <your_username> --noclear %I $TERM
sudo EDITOR=vim systemctl edit getty@tty1.service
# 在打开的编辑器中添加:
# [Service]
# ExecStart=
# ExecStart=-/sbin/agetty --autologin <your_username> --noclear %I $TERM
```
其中:

View File

@@ -10,16 +10,24 @@ import (
func SoundStart() leaf.HandlerFunc {
return func(c *leaf.Context) {
pm := leaf.Value[*schema.PlayModal](c, PayloadJSONKey)
tts.DefaultTTS.Sound(pm.TTS.Start)
// 使用请求的 context支持取消和超时
if pm.TTS.Start != "" {
tts.DefaultTTS.SoundWithContext(c, pm.TTS.Start)
}
defer func() {
var text string
switch leaf.Value[leaf.EndType](c, leaf.EndKey) {
case leaf.End:
tts.DefaultTTS.Sound(pm.TTS.End)
text = pm.TTS.End
case leaf.EndTimeout:
tts.DefaultTTS.Sound(pm.TTS.Timeout)
text = pm.TTS.Timeout
case leaf.EndStop:
tts.DefaultTTS.Sound(pm.TTS.Stop)
text = pm.TTS.Stop
}
if text != "" {
tts.DefaultTTS.SoundWithContext(c, text)
}
}()

View File

@@ -6,14 +6,15 @@ import (
"game-driver/leaf"
"game-driver/pkg/utils"
"game-driver/pkg/video"
"go.uber.org/zap"
)
func OnlyVideo(c *leaf.Context) {
payload := leaf.Value[*schema.PlayModal](c, middleware.PayloadJSONKey)
utils.BlankOpen()
defer utils.BlankClose()
// utils.BlankOpen()
// defer utils.BlankClose()
if url, ok := payload.Game["video"]; ok {
path, local, err := utils.LinkVideo(url.(string))

View File

@@ -6,6 +6,7 @@ import (
"game-driver/internal/schema"
"game-driver/pkg/utils"
"game-driver/pkg/video"
"go.uber.org/zap"
)
@@ -19,8 +20,8 @@ func Video(item schema.WaitItemModel) func(c context.Context) error {
zap.S().Infoln("播放待机视频")
defer zap.S().Infoln("结束待机视频")
utils.BlankOpen()
defer utils.BlankClose()
// utils.BlankOpen()
// defer utils.BlankClose()
err = video.Play(c, path, local)
if err != nil {

View File

@@ -4,7 +4,7 @@ import (
"context"
"game-driver/internal/schema"
"game-driver/pkg/browser"
"game-driver/pkg/utils"
"go.uber.org/zap"
)
@@ -13,8 +13,8 @@ func Web(item schema.WaitItemModel) func(c context.Context) error {
zap.S().Infoln("打开待机网页")
// 控制背光
utils.BlankOpen()
defer utils.BlankClose()
// utils.BlankOpen()
// defer utils.BlankClose()
browser.OpenApp(c, item.Data)
return nil

View File

@@ -83,13 +83,13 @@ func Run() {
cls, err := logger.NewTenCls(fmt.Sprintf("game-driver-%s-%v", config.C.Location, config.C.Point))
if err != nil {
log.Println("初始化腾讯云日志服务异常: ", err)
logger.InitDevLogger()
} else {
cls.Start()
defer cls.Close()
logger.InitProLogger(cls)
}
cls.Start()
defer cls.Close()
logger.InitProLogger(cls)
//logger.InitDevLogger()
// 应用退出时刷新所有缓冲日志
defer logger.Sync()
@@ -101,7 +101,7 @@ func Run() {
zap.S().Infoln("当前IP: ", addrs)
// 启动时关闭屏幕
utils.BlankClose()
// utils.BlankClose()
topicPrefix := fmt.Sprintf("server/%s/%v/", config.C.Location, config.C.Point)
publishTopic := fmt.Sprintf("device/%s/%v/status", config.C.Location, config.C.Point)

View File

@@ -22,29 +22,37 @@ func init() {
}
func PlayWav(c context.Context, r io.Reader) {
zap.S().Debugln("开始 WAV 解码")
streamer, format, err := wav.Decode(r)
if err != nil {
zap.S().Errorln("WAV解码失败: ", err)
return
}
defer streamer.Close()
zap.S().Debugln("WAV解码成功采样率:", format.SampleRate)
s := beep.Resample(4, format.SampleRate, DefaultSampleRate, streamer)
ctrl := &beep.Ctrl{Streamer: s}
done := make(chan struct{})
speaker.Play(beep.Seq(ctrl, beep.Callback(func() {
zap.S().Debugln("音频播放完成")
close(done)
})))
zap.S().Debugln("等待音频播放完成...")
for {
select {
case <-done:
zap.S().Infoln("音频播放正常结束")
return
case <-c.Done():
{
zap.S().Infoln("音频播放被 context 取消")
speaker.Lock()
ctrl.Streamer = nil
speaker.Unlock()
return
}
}
}
@@ -53,6 +61,7 @@ func PlayWav(c context.Context, r io.Reader) {
func PlayMP3(c context.Context, r io.ReadCloser) {
streamer, format, err := mp3.Decode(r)
if err != nil {
zap.S().Errorln("MP3解码失败: ", err)
return
}
defer streamer.Close()
@@ -74,6 +83,7 @@ func PlayMP3(c context.Context, r io.ReadCloser) {
speaker.Lock()
ctrl.Streamer = nil
speaker.Unlock()
return
}
}
}

View File

@@ -14,51 +14,61 @@ func New(host string, port int) *Client {
}
}
// StartCue 播放节目
func (c *Client) StartCue(data string) error {
msg := osc.NewMessage("/beyond/general/StartCue", data)
return c.o.Send(msg)
}
// EnableLaserOutput 打开激光
func (c *Client) EnableLaserOutput() error {
msg := osc.NewMessage("/beyond/general/EnableLaserOutput")
return c.o.Send(msg)
}
// DisableLaserOutput 关闭激光
func (c *Client) DisableLaserOutput() error {
msg := osc.NewMessage("/beyond/general/DisableLaserOutput")
return c.o.Send(msg)
}
// SetLaserOutput 设置激光输出
func (c *Client) SetLaserOutput(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutput", data)
return c.o.Send(msg)
}
// SetLaserOutputColor 设置激光颜色
func (c *Client) SetLaserOutputColor(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputColor", data)
return c.o.Send(msg)
}
// SetLaserOutputIntensity 设置激光强度
func (c *Client) SetLaserOutputIntensity(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputIntensity", data)
return c.o.Send(msg)
}
// SetLaserOutputPosition 设置激光位置
func (c *Client) SetLaserOutputPosition(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputPosition", data)
return c.o.Send(msg)
}
// SetLaserOutputSize 设置激光尺寸
func (c *Client) SetLaserOutputSize(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputSize", data)
return c.o.Send(msg)
}
// SetLaserOutputSpeed 设置激光速度
func (c *Client) SetLaserOutputSpeed(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputSpeed", data)
return c.o.Send(msg)
}
// Status 获取状态
func (c *Client) Status() error {
msg := osc.NewMessage("/beyond/general/Status")
return c.o.Send(msg)

View File

@@ -45,9 +45,28 @@ func (tts *AliTTS) Sound(text string) {
if text == "" {
return
}
zap.S().Infof("开始播放TTS: %s", text)
buf, err := tts.Get(text)
if err == nil && buf != nil {
zap.S().Debugln("TTS合成成功开始播放")
audio.PlayWav(tts.ctx, buf)
zap.S().Infof("TTS播放完成: %s", text)
} else {
zap.S().Errorln("AliTTS 请求异常: ", err)
}
}
// SoundWithContext 使用指定的 context 播放 TTS支持取消和超时
func (tts *AliTTS) SoundWithContext(ctx context.Context, text string) {
if text == "" {
return
}
zap.S().Infof("开始播放TTS: %s", text)
buf, err := tts.Get(text)
if err == nil && buf != nil {
zap.S().Debugln("TTS合成成功开始播放")
audio.PlayWav(ctx, buf)
zap.S().Infof("TTS播放完成: %s", text)
} else {
zap.S().Errorln("AliTTS 请求异常: ", err)
}
@@ -110,13 +129,22 @@ func (tts *AliTTS) Get(text string) (io.Reader, error) {
case done := <-ch:
{
if !done {
zap.S().Errorln("TTS合成失败: done=false")
return ttsData.Data, errorsx.ThirdPartyErr
}
size := ttsData.Data.(*bytes.Buffer).Len()
zap.S().Debugf("TTS合成成功数据大小: %d bytes", size)
if size == 0 {
zap.S().Errorln("TTS合成数据为空")
return ttsData.Data, errorsx.ThirdPartyErr
}
return ttsData.Data, nil
}
case <-time.After(time.Duration(tts.Timeout) * time.Second):
zap.S().Errorln("TTS合成超时")
return ttsData.Data, errorsx.DriverTimeoutErr
case <-tts.ctx.Done():
zap.S().Errorln("TTS合成被取消")
return ttsData.Data, errorsx.DriverCancelErr
}
}

View File

@@ -3,12 +3,26 @@ package utils
import (
"os"
"os/exec"
"sync"
)
var (
xsetBin string
once sync.Once
)
func init() {
once.Do(func() {
if found, err := exec.LookPath("xset"); err == nil {
xsetBin = found
}
})
}
// BlankOpen 打开屏幕
func BlankOpen() {
if found, err := exec.LookPath("xset"); err == nil {
exec.Command(found, "dpms", "force", "on").Run()
if xsetBin != "" {
exec.Command(xsetBin, "dpms", "force", "on").Run()
return
}
os.WriteFile("/sys/class/graphics/fb0/blank", []byte("0"), 0644)
@@ -16,8 +30,8 @@ func BlankOpen() {
// BlankClose 关闭屏幕
func BlankClose() {
if found, err := exec.LookPath("xset"); err == nil {
exec.Command(found, "dpms", "force", "off").Run()
if xsetBin != "" {
exec.Command(xsetBin, "dpms", "force", "off").Run()
return
}
os.WriteFile("/sys/class/graphics/fb0/blank", []byte("1"), 0644)

16
todo.md
View File

@@ -4,17 +4,21 @@
```bash
# 在 systemd-networkd-wait-online.service Service 加入 TimeoutStartSec=2sec
sudo vim /etc/systemd/system/network-online.target.wants/systemd-networkd-wait-online.service
sudo EDITOR=vim systemctl edit systemd-networkd-wait-online.service
# 在打开的编辑器中添加:
# [Service]
# TimeoutStartSec=2sec
```
### 配置时区
```bash
sudo timedatectl set-timezone Asia/Shanghai
```
## linux 下播放音频
```bash
```bash
sudo apt install libasound2-dev alsa-utils
```
@@ -107,10 +111,14 @@ fi
### 自动登录
编辑 `/etc/systemd/system/getty.target.wants/getty@tty1.service` 文件,将 `ExecStart` 行修改为
使用 systemctl edit 修改 getty@tty1 服务
```bash
ExecStart=-/sbin/agetty --autologin <your_username> --noclear %I $TERM
sudo EDITOR=vim systemctl edit getty@tty1.service
# 在打开的编辑器中添加:
# [Service]
# ExecStart=
# ExecStart=-/sbin/agetty --autologin <your_username> --noclear %I $TERM
```
其中: