前言

目标是将一部性能尚可的旧手机(OnePlus 6),通过刷入 postmarketOS,打造成一台低功耗、高效率的家庭服务器。需要 24/7 不间断运行,承载各种任务。

这个计划最大的问题,就是手机内置的那块锂电池。如果让常年维持在 100% 的充电状态,电池的化学老化会急剧加速,最终导致容量暴跌和物理损坏(鼓包)。

因此,项目的核心任务诞生了:创建一个全自动的电池管理系统,将电量维持在对电池最友好的 40%-80% “黄金养护区间”内。

碰壁——无法在手机内部控制充电

最直接的思路,是在 postmarketOS 内部通过脚本控制充电。理论上,Linux 系统通过 /sys 虚拟文件系统暴露了所有硬件的控制接口,我们只需要找到对应的文件并写入 01 即可。但现实给了我们沉重的一击。

我们尝试了所有可能的软件控制路径,但都以失败告终:

  • **尝试控制 current_max**:直接向充电芯片的电流上限文件写入 0,但驱动程序或硬件本身有自己的逻辑,会立即将该值强制修正回一个最低的充电档位(0.5A),我们甚至无法命令停止。
  • **尝试“取消授权”USB设备 (authorized)**:通过 echo 0 > .../authorized 命令让内核“断开”USB设备,但 PVE 主机上强大的 udev 服务会立即检测到变化并自动重新授权,陷入了毫秒级的“拉锯战”。
  • **尝试“挂起”USB设备 (autosuspend)**:尝试命令内核将 USB 端口挂起以断电,但因为手机开启了 USB 网络共享,活动的 cdc_ncm 网络驱动会向内核报告“设备正忙”,拒绝挂起。
  • 尝试 uhubctl 硬件控制:试图通过 PVE 主机直接控制 USB Hub 的端口电源,但主板硬件本身不支持该功能。

结论:在手机内部通过软件控制充电的道路被彻底堵死。我们需要一个能从物理层面切断电源的“执行者”。

引入智能插座与 python-miio

既然无法控制手机,我们就控制充电器。

社区最成熟的控制工具是 python-miio,提供了一个命令行工具 miiocli,完美符合我们的脚本化需求。

部署 python-miio

python-miio

1
2
3
4
5
# 创建虚拟环境
python -m venv /path/to/miio
source /path/to/miio/bin/activate
# 安装
pip install git+https://github.com/rytilahti/python-miio.git

在一个精简的 postmarketOS (基于 Alpine Linux) 和较新的 Python 3.12 环境下,我们经历了一连串的依赖和编译问题。

  1. 初次安装失败 -> 缺少编译器

    • 报错error: command 'gcc' failed: No such file or directory
    • 原因python-miio 的依赖 netifaces 包含 C 代码,需要 GCC 编译器才能安装,而系统默认没有安装。
    • 解决:安装编译工具集 sudo apk add build-base python3-dev
  2. 二次安装失败 -> 缺少内核头文件

    • 报错fatal error: linux/tipc.h: No such file or directory
    • 原因:编译 C 代码需要引用 Linux 内核的头文件,系统默认也没有安装。
    • 解决:安装内核头文件包 sudo apk add linux-headers
  3. 命令运行崩溃 (TypeError) -> 依赖版本冲突

    • 报错:安装成功后,运行 miiocli 命令直接崩溃,抛出 TypeError: argument of type 'bool' is not iterable

    • 原因:经过大量排查,最终发现是 python-miio 所依赖的命令行框架库 click 的最新版本与 python-miio 不兼容。

    • 解决:强制降级 click 库到一个已知的稳定版本。这是整个部署过程中最关键的突破。

      1
      pip install click==8.0.4 --force-reinstall

获取 Token 与寻找正确命令

解决了环境问题,我们开始尝试控制插座。这又是一段曲折的探索。

  1. 获取 Token:本地控制小米设备需要一个32位的 Token 秘钥。
    • miiocli 自带的 miiocli cloud 命令因小米云服务新的安全策略而登录失败。
    • 最终,我们使用了另一个独立的云端提取工具 Xiaomi-cloud-tokens-extractor用米家app扫描二维码登录,获取到了 Token。
  2. 寻找控制命令
    • **chuangmiplug**:设备专用命令,但因之前的 TypeError 问题被我们跳过。
    • **genericmiot set switch:on true**:根据设备规格推断出的命令,却返回 ValueError: Unable to find setting 'switch:on'
    • **genericmiot set_property_by 2 1 1**:换用更底层的数字ID,却返回 code: -4005 (设备正忙)。
    • 最终的正确命令:在关闭了所有干扰功能后,我们终于试出了那条隐藏在文档深处、独一无二的正确格式。**要求在命令末尾明确指定数据类型 bool**。

至此,我们得到了命令:

1
2
3
4
5
6
7
8
# 获取状态
miiocli genericmiot --ip <IP> --token <Token> status

# 打开插座
miiocli genericmiot --ip <IP> --token <Token> set_property_by 2 1 True bool

# 关闭插座
miiocli genericmiot --ip <IP> --token <Token> set_property_by 2 1 False bool

最终成果

在掌握了可靠的物理控制命令后,我们终于可以编写最终的自动化脚本了。不仅是一个控制器,更是一个具备自我修正能力的“守护神”。

核心逻辑

  • 主动查询:每分钟通过网络获取插座的真实开关状态。
  • 守护逻辑
    • 如果电量 >= 80%,但脚本发现插座状态竟然是 on,会立刻发送 off 命令强制关闭,并发送一条“守护警报”通知。
    • 如果电量 <= 40%,但脚本发现插座状态竟然是 off,会立刻发送 on 命令强制开启,并发送警报。
  • 智能通知:只有在策略发生“计划内”的切换时才发送常规通知,避免骚扰。
  • 周期报告:定期发送包含电池健康度、温度、电压、电流等信息的完整报告。

最终脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#!/bin/sh

# ==============================================================================
# Self-Correcting Smart Plug Battery Manager
# ==============================================================================

# --- 硬件与逻辑配置 ---
CAPACITY_PATH="/sys/class/power_supply/bq27411-0/capacity"
START_CHARGE_THRESHOLD=40
STOP_CHARGE_THRESHOLD=80

# --- 日志配置 ---
LOG_FILE="/var/log/charge_manager.log"
MAX_LOG_LINES=200

# --- Telegram 配置 ---
TELEGRAM_TOKEN="<你的Telegram Bot Token>"
CHAT_ID="<你的Telegram Chat ID>"
TELEGRAM_ENABLED=true

# --- 代理配置 ---
PROXY_ENABLED=true
PROXY_URL="http://127.0.0.1:7890"

# --- 小米智能插座配置 ---
PLUG_IP="<你的插座IP>"
PLUG_TOKEN="<你的插座Token>"
MIIOCLI_PATH="/home/user/python/miio/bin/miiocli"

# ==============================================================================
# 脚本函数定义
# ==============================================================================

# 函数:记录日志
log_message() {
local message="$1"
echo "[$(date "+%Y-%m-%d %H:%M:%S")] ${message}" >> "$LOG_FILE"
}

# 函数:发送 Telegram 通知
send_telegram_notification() {
if [ "$TELEGRAM_ENABLED" = true ]; then
local message="$1"; local proxy_option=""
if [ "$PROXY_ENABLED" = true ]; then proxy_option="-x ${PROXY_URL}"; fi
/usr/bin/curl -s -o /dev/null ${proxy_option} --data-urlencode "chat_id=${CHAT_ID}" --data-urlencode "text=${message}" "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage"
fi
}

# 函数:清理旧日志
cleanup_log() {
if [ -f "$LOG_FILE" ]; then
tail -n "$MAX_LOG_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
fi
}

# 函数:获取插座状态 ("on" / "off" / "error")
get_plug_status() {
local status_output; status_output=$("$MIIOCLI_PATH" genericmiot --ip "$PLUG_IP" --token "$PLUG_TOKEN" status 2>/dev/null)
if echo "$status_output" | grep -q "Switch Status (switch:on, access: RW): True"; then echo "on";
elif echo "$status_output" | grep -q "Switch Status (switch:on, access: RW): False"; then echo "off";
else echo "error"; fi
}

# 函数:控制插座开关
set_plug_state() {
local action="$1"; local value=""
if [ "$action" = "on" ]; then value="True"; else value="False"; fi
"$MIIOCLI_PATH" genericmiot --ip "$PLUG_IP" --token "$PLUG_TOKEN" set_property_by 2 1 "$value" bool >/dev/null 2>&1
}

# 函数:发送周期性状态报告
send_periodic_status_update() {
local health_path="/sys/class/power_supply/pmi8998-charger/health"
local temp_path="/sys/class/power_supply/bq27411-0/temp"
local voltage_path="/sys/class/power_supply/bq27411-0/voltage_now"
local current_path="/sys/class/power_supply/bq27411-0/current_now"
local capacity=$(cat "$CAPACITY_PATH"); local health=$(cat "$health_path");
local temp_raw=$(cat "$temp_path"); local temp_c=$(echo "$temp_raw" | awk '{printf "%.1f", $1/10}');
local voltage_uv=$(cat "$voltage_path"); local voltage_mv=$(($voltage_uv / 1000));
local current_ua=$(cat "$current_path"); local current_ma=$(($current_ua / 1000));
local current_status_text="";
if [ "$current_ma" -gt 0 ]; then current_status_text="⚡️ 正在充电";
elif [ "$current_ma" -lt 0 ]; then current_status_text=" discharging ";
else current_status_text="~ 待机"; fi
local plug_state_text=$(get_plug_status);
if [ "$plug_state_text" = "on" ]; then plug_state_text="开启";
elif [ "$plug_state_text" = "off" ]; then plug_state_text="关闭";
else plug_state_text="状态未知"; fi
local message="🕒 周期健康报告:
- 电池电量: ${capacity}%
- 电池健康: ${health}
- 电池温度: ${temp_c}°C
- 实时电压: ${voltage_mv}mV
- 实时电流: ${current_ma}mA (${current_status_text})
- 智能插座: ${plug_state_text}"
log_message "发送周期性健康报告。"; send_telegram_notification "$message"
}

# ==============================================================================
# 主程序开始
# ==============================================================================
log_message "守护式充电管理服务启动 (v14)。策略: ${START_CHARGE_THRESHOLD}%-${STOP_CHARGE_THRESHOLD}%"
send_telegram_notification "🔋 守护式充电管理服务已启动 (v14)!"

last_notified_action="stopped"
log_cleanup_count=0; periodic_report_count=0

while true; do
capacity=$(cat "$CAPACITY_PATH"); current_plug_status=$(get_plug_status)
if [ "$current_plug_status" = "error" ]; then log_message "错误:无法获取智能插座状态,1分钟后重试。"; sleep 60; continue; fi
if [ "$capacity" -ge "$STOP_CHARGE_THRESHOLD" ]; then
if [ "$current_plug_status" = "on" ]; then
set_plug_state "off"
if [ "$last_notified_action" = "charging" ]; then
message="✅ 电量已达 ${capacity}%,已自动关闭智能插座。"; log_message "$message"; send_telegram_notification "$message"; last_notified_action="stopped"
else
message="🚨 守护警报:电量高于 ${STOP_CHARGE_THRESHOLD}%,但检测到插座意外开启,已强制关闭!"; log_message "$message"; send_telegram_notification "$message"
fi
fi
elif [ "$capacity" -le "$START_CHARGE_THRESHOLD" ]; then
if [ "$current_plug_status" = "off" ]; then
set_plug_state "on"
if [ "$last_notified_action" = "stopped" ]; then
message="⚠️ 电量已低至 ${capacity}%,已自动打开智能插座。"; log_message "$message"; send_telegram_notification "$message"; last_notified_action="charging"
else
message="🚨 守护警报:电量低于 ${START_CHARGE_THRESHOLD}%,但检测到插座意外关闭,已强制开启!"; log_message "$message"; send_telegram_notification "$message"
fi
fi
fi
log_cleanup_count=$((log_cleanup_count+1)); periodic_report_count=$((periodic_report_count+1))
if [ "$log_cleanup_count" -ge 60 ]; then cleanup_log; log_cleanup_count=0; fi
if [ "$periodic_report_count" -ge 120 ]; then send_periodic_status_update; periodic_report_count=0; fi
sleep 60
done