Initial release: Pigway Pi Control v3.1.0
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.log
|
||||
@@ -0,0 +1,381 @@
|
||||
# Pigway Pi Control v3.1.0 — Release
|
||||
|
||||
适用于本套 Raspberry Pi 4B 散热扩展板的正式发布版。
|
||||
|
||||
本包遵循一个原则:
|
||||
|
||||
> **一个程序 + 一个用户配置文件 + 一个说明文档。**
|
||||
|
||||
正常使用时,不需要修改源码。
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能
|
||||
|
||||
### OLED
|
||||
|
||||
配套 OLED 按卖家原生参数锁定为:
|
||||
|
||||
- SSD1306
|
||||
- 128×32
|
||||
- I²C `0x3C`
|
||||
- MUX `0x1F`
|
||||
- COM Pins `0x02`
|
||||
- Page `0..3`
|
||||
- 卖家同源 5×7 点阵字体
|
||||
- 4行:`0 / 8 / 16 / 24`
|
||||
|
||||
主页:
|
||||
|
||||
```text
|
||||
CPU 18.6% MEM 16.2%
|
||||
TMP 46.3C FAN OFF
|
||||
DSK 22.4% NET OK
|
||||
IP4 192.168.89.130
|
||||
```
|
||||
|
||||
IPv6 地址时自动显示 `IP6`。
|
||||
|
||||
主页左右两栏都是固定位置、左对齐。数据默认每秒刷新。
|
||||
|
||||
有告警时,OLED 会在 HOME 与所有当前告警之间轮播;默认每页停留10秒。
|
||||
|
||||
### 风扇
|
||||
|
||||
默认:
|
||||
|
||||
```text
|
||||
< 50°C OFF
|
||||
50~53°C L1
|
||||
53~56°C L2
|
||||
56~59°C L3
|
||||
59~62°C L4
|
||||
>= 62°C MAX
|
||||
```
|
||||
|
||||
带降档回差,避免临界温度反复升降档。
|
||||
|
||||
### RGB 告警
|
||||
|
||||
正常状态 RGB 完全熄灭。
|
||||
|
||||
颜色表示“什么对象出问题”:
|
||||
|
||||
```text
|
||||
CPU 黄
|
||||
温度 红
|
||||
内存 紫
|
||||
磁盘 橙
|
||||
网络 蓝
|
||||
服务/进程 青
|
||||
```
|
||||
|
||||
所有告警都使用闪烁;闪烁速度表示严重程度:
|
||||
|
||||
```text
|
||||
Warning 慢
|
||||
Critical 中
|
||||
Emergency 快
|
||||
```
|
||||
|
||||
亮度可配置,默认10%,适合暗环境。
|
||||
|
||||
### Dark Mode
|
||||
|
||||
默认关闭 Raspberry Pi 自身:
|
||||
|
||||
- PWR LED
|
||||
- ACT LED
|
||||
|
||||
散热板 D1/D8 黄色硬件状态灯和独立电源模块绿色灯不属于已确认的软件控制接口,本程序不操作。
|
||||
|
||||
---
|
||||
|
||||
## 2. 全新 Raspberry Pi OS 安装
|
||||
|
||||
### 第一步:启用 I²C
|
||||
|
||||
```bash
|
||||
sudo raspi-config
|
||||
```
|
||||
|
||||
进入:
|
||||
|
||||
```text
|
||||
Interface Options
|
||||
→ I2C
|
||||
→ Enable
|
||||
```
|
||||
|
||||
然后:
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
### 第二步:解压本安装包
|
||||
|
||||
进入解压后的目录:
|
||||
|
||||
```bash
|
||||
cd pigway-pi-control-v3.1.0
|
||||
```
|
||||
|
||||
### 第三步:安装
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
安装器会:
|
||||
|
||||
1. 检查 `/dev/i2c-1`
|
||||
2. 等待 APT/DPKG 锁
|
||||
3. 安装必要的小型系统依赖
|
||||
4. 检查 I²C 总线
|
||||
5. 自动停止并禁用卖家旧 `temp-control.service`
|
||||
6. 安装正式程序
|
||||
7. 安装用户配置
|
||||
8. 安装 systemd 服务并开机自启
|
||||
9. 执行安装后自检
|
||||
|
||||
安装成功后查看:
|
||||
|
||||
```bash
|
||||
systemctl status pigway-pi-control --no-pager -l
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 安装后的文件在哪里
|
||||
|
||||
### 用户应该修改
|
||||
|
||||
只有:
|
||||
|
||||
```text
|
||||
/etc/pigway-pi-control.conf
|
||||
```
|
||||
|
||||
编辑:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/pigway-pi-control.conf
|
||||
```
|
||||
|
||||
保存后:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control
|
||||
```
|
||||
|
||||
### 用户通常不要修改
|
||||
|
||||
程序:
|
||||
|
||||
```text
|
||||
/usr/local/sbin/pigway-pi-control
|
||||
```
|
||||
|
||||
systemd:
|
||||
|
||||
```text
|
||||
/etc/systemd/system/pigway-pi-control.service
|
||||
```
|
||||
|
||||
这些由安装包维护。
|
||||
|
||||
因此安装成功后,解压出来的 `pigway-pi-control-v3.1.0/` 文件夹可以删除。
|
||||
|
||||
建议把 ZIP 保存到 Mac / NAS,供以后重装。
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户可以自定义什么
|
||||
|
||||
全部集中在:
|
||||
|
||||
```text
|
||||
/etc/pigway-pi-control.conf
|
||||
```
|
||||
|
||||
其中包括:
|
||||
|
||||
- 数据采样周期
|
||||
- OLED刷新周期
|
||||
- 告警页面停留时间
|
||||
- 网络检查周期
|
||||
- 服务检查周期
|
||||
- 风扇温度曲线
|
||||
- 风扇降档回差
|
||||
- CPU告警阈值和持续时间
|
||||
- 内存告警阈值和持续时间
|
||||
- 温度普通/严重告警
|
||||
- 磁盘普通/严重告警
|
||||
- RGB亮度
|
||||
- Warning/Critical/Emergency 闪烁周期
|
||||
- Pi PWR/ACT灯
|
||||
- 要监控的systemd服务
|
||||
- 要监控的普通进程
|
||||
|
||||
**每个参数在配置文件中都已经写有详细中文注释。**
|
||||
|
||||
---
|
||||
|
||||
## 5. 增加一个服务监控
|
||||
|
||||
编辑:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/pigway-pi-control.conf
|
||||
```
|
||||
|
||||
找到:
|
||||
|
||||
```ini
|
||||
[services]
|
||||
SSH = ssh.service
|
||||
AWESUN = runawesun.service
|
||||
```
|
||||
|
||||
例如安装了 Tailscale:
|
||||
|
||||
```ini
|
||||
TAILSCALE = tailscaled.service
|
||||
```
|
||||
|
||||
保存后:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control
|
||||
```
|
||||
|
||||
先确认服务真实名称:
|
||||
|
||||
```bash
|
||||
systemctl status tailscaled.service
|
||||
```
|
||||
|
||||
暂时不想监控,行首加 `#`:
|
||||
|
||||
```ini
|
||||
# TAILSCALE = tailscaled.service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 监控没有 systemd service 的程序
|
||||
|
||||
配置:
|
||||
|
||||
```ini
|
||||
[processes]
|
||||
MYAPP = /opt/myapp/server
|
||||
```
|
||||
|
||||
先测试:
|
||||
|
||||
```bash
|
||||
pgrep -af '/opt/myapp/server'
|
||||
```
|
||||
|
||||
能找到进程再加入监控。
|
||||
|
||||
---
|
||||
|
||||
## 7. 常用维护
|
||||
|
||||
状态:
|
||||
|
||||
```bash
|
||||
systemctl status pigway-pi-control --no-pager -l
|
||||
```
|
||||
|
||||
日志:
|
||||
|
||||
```bash
|
||||
journalctl -u pigway-pi-control -f
|
||||
```
|
||||
|
||||
重启:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart pigway-pi-control
|
||||
```
|
||||
|
||||
停止:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop pigway-pi-control
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
sudo systemctl start pigway-pi-control
|
||||
```
|
||||
|
||||
查看配置:
|
||||
|
||||
```bash
|
||||
cat /etc/pigway-pi-control.conf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 升级
|
||||
|
||||
新版安装包直接:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
安装器会保留用户已有的 `[services]` 和 `[processes]` 监控列表,并备份旧配置。
|
||||
|
||||
升级后检查:
|
||||
|
||||
```bash
|
||||
systemctl status pigway-pi-control --no-pager -l
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 卸载
|
||||
|
||||
在安装包目录执行:
|
||||
|
||||
```bash
|
||||
sudo ./uninstall.sh
|
||||
```
|
||||
|
||||
卸载程序默认保留:
|
||||
|
||||
```text
|
||||
/etc/pigway-pi-control.conf
|
||||
```
|
||||
|
||||
避免误删用户自定义配置。
|
||||
|
||||
确认以后不再使用时可手工删除:
|
||||
|
||||
```bash
|
||||
sudo rm /etc/pigway-pi-control.conf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 重装树莓派时最短流程
|
||||
|
||||
以后即使完全忘记之前怎么安装,只需要:
|
||||
|
||||
```text
|
||||
1. 安装 Raspberry Pi OS
|
||||
2. raspi-config 开启 I²C
|
||||
3. 重启
|
||||
4. 解压本 ZIP
|
||||
5. cd pigway-pi-control-v3.1.0
|
||||
6. sudo ./install.sh
|
||||
```
|
||||
|
||||
完成。
|
||||
@@ -0,0 +1,7 @@
|
||||
ef212ae05c0fedd3d8b9e83b6a7d0c7f2bff08a9292c306ce081f04c50fc1998 README.md
|
||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||
0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py
|
||||
48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf
|
||||
7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c install.sh
|
||||
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
|
||||
a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh
|
||||
Binary file not shown.
Executable
+367
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
import os,time,socket,subprocess,configparser,signal
|
||||
from pathlib import Path
|
||||
APP_VERSION="3.1.0"
|
||||
from smbus2 import SMBus
|
||||
from PIL import Image
|
||||
from shutil import disk_usage
|
||||
|
||||
OLED=0x3C; MCU=0x0D; W,H=128,32
|
||||
CFG="/etc/pigway-pi-control.conf"
|
||||
running=True
|
||||
|
||||
def stop(*_):
|
||||
global running; running=False
|
||||
signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop)
|
||||
|
||||
bus=SMBus(1)
|
||||
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin"
|
||||
FONT5=Path(FONT_PATH).read_bytes()
|
||||
fan_level=0
|
||||
prev_cpu=None
|
||||
page=0; page_since=time.monotonic()
|
||||
cpu_hi=mem_hi=0
|
||||
net_ok=link_ok=True
|
||||
ip="NO IP"
|
||||
last_net=last_watch=0
|
||||
watch_state=[]
|
||||
|
||||
def cmd(c): bus.write_byte_data(OLED,0x00,c)
|
||||
def oled_init():
|
||||
# Seller-native physical 128x32 configuration.
|
||||
for c in [0xAE,0xD5,0x80,0xA8,0x1F,0xD3,0x00,0x40,0x8D,0x14,
|
||||
0x20,0x00,0xA1,0xC8,0xDA,0x02,0x81,0x7F,0xD9,0xF1,
|
||||
0xDB,0x40,0xA4,0xA6,0xAF]:
|
||||
cmd(c)
|
||||
|
||||
def draw_text_5x7(pix,x,y,text):
|
||||
for ch in text:
|
||||
code=ord(ch)
|
||||
if code<0 or code>255: code=ord("?")
|
||||
off=code*5
|
||||
if off+5>len(FONT5): code=ord("?"); off=code*5
|
||||
for cx in range(5):
|
||||
col=FONT5[off+cx]
|
||||
for cy in range(7):
|
||||
if col & (1<<cy):
|
||||
xx=x+cx; yy=y+cy
|
||||
if 0<=xx<W and 0<=yy<H: pix[xx,yy]=1
|
||||
x+=6
|
||||
if x>W-6: break
|
||||
|
||||
def oled_show(lines_lr, title_zh=False):
|
||||
im=Image.new("1",(W,H),0); pix=im.load()
|
||||
for row,(left,right) in enumerate(lines_lr[:4]):
|
||||
y=row*8
|
||||
draw_text_5x7(pix,0,y,left)
|
||||
if right:
|
||||
draw_text_5x7(pix,max(0,W-len(right)*6),y,right)
|
||||
data=[]
|
||||
for pg in range(4):
|
||||
for x in range(W):
|
||||
v=0
|
||||
for bit in range(8):
|
||||
if pix[x,pg*8+bit]: v|=1<<bit
|
||||
data.append(v)
|
||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
||||
for i in range(0,len(data),16):
|
||||
bus.write_i2c_block_data(OLED,0x40,data[i:i+16])
|
||||
|
||||
|
||||
def mcu(reg,val): bus.write_byte_data(MCU,reg,val)
|
||||
_last_rgb=None
|
||||
def rgb_off():
|
||||
global _last_rgb
|
||||
if _last_rgb != ("off",):
|
||||
mcu(0x07,0x00) # seller closeRGB(): true OFF
|
||||
_last_rgb=("off",)
|
||||
|
||||
def rgb(r,g,b):
|
||||
global _last_rgb
|
||||
scale=max(0,min(100,LED_BRIGHT))/100.0
|
||||
state=(int(r*scale),int(g*scale),int(b*scale))
|
||||
if _last_rgb==state: return
|
||||
mcu(0x07,0x00) # stop built-in effect first
|
||||
mcu(0x00,0xff) # seller protocol: >=3 => all three LEDs
|
||||
mcu(0x01,state[0]); mcu(0x02,state[1]); mcu(0x03,state[2])
|
||||
_last_rgb=state
|
||||
|
||||
def set_pi_led(name,off=True):
|
||||
base=Path("/sys/class/leds")/name
|
||||
try:
|
||||
if off:
|
||||
(base/"trigger").write_text("none")
|
||||
(base/"brightness").write_text("0")
|
||||
except Exception as e:
|
||||
print(f"board LED {name}: {e}",flush=True)
|
||||
|
||||
def apply_dark_mode():
|
||||
if DARK_PWR: set_pi_led("PWR",True)
|
||||
if DARK_ACT: set_pi_led("ACT",True)
|
||||
|
||||
FAN_REG=[0x00,0x04,0x06,0x08,0x09,0x01]
|
||||
FAN_NAME=["OFF","L1","L2","L3","L4","MAX"]
|
||||
def fan_target(t):
|
||||
if t<FAN_T[0]: return 0
|
||||
if t<FAN_T[1]: return 1
|
||||
if t<FAN_T[2]: return 2
|
||||
if t<FAN_T[3]: return 3
|
||||
if t<FAN_T[4]: return 4
|
||||
return 5
|
||||
def update_fan(t):
|
||||
global fan_level
|
||||
want=fan_target(t); old=fan_level
|
||||
if want>fan_level: fan_level=want
|
||||
elif want<fan_level:
|
||||
boundary = FAN_T[max(0,fan_level-1)] - FAN_HYST
|
||||
if t < boundary: fan_level-=1
|
||||
if old!=fan_level: mcu(0x08,FAN_REG[fan_level])
|
||||
|
||||
def cpu_pct():
|
||||
global prev_cpu
|
||||
p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9]))
|
||||
idle=p[3]+p[4]; total=sum(p)
|
||||
if prev_cpu is None: prev_cpu=(idle,total); return 0.0
|
||||
di=idle-prev_cpu[0]; dt=total-prev_cpu[1]; prev_cpu=(idle,total)
|
||||
return 100.0*(dt-di)/dt if dt else 0.0
|
||||
def temp_c(): return int(Path("/sys/class/thermal/thermal_zone0/temp").read_text())/1000.0
|
||||
def mem_pct():
|
||||
vals={}
|
||||
for l in Path("/proc/meminfo").read_text().splitlines():
|
||||
if ":" in l:
|
||||
k,v=l.split(":",1); vals[k]=int(v.split()[0])
|
||||
return 100*(vals["MemTotal"]-vals["MemAvailable"])/vals["MemTotal"]
|
||||
def disk_pct():
|
||||
d=disk_usage("/"); return 100*d.used/d.total
|
||||
def get_ip():
|
||||
try:
|
||||
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(("1.1.1.1",53));v=s.getsockname()[0];s.close();return v
|
||||
except: return "NO IP"
|
||||
def internet():
|
||||
try:
|
||||
s=socket.create_connection(("1.1.1.1",53),0.5);s.close();return True
|
||||
except: return False
|
||||
|
||||
def load_cfg():
|
||||
c=configparser.ConfigParser(); c.read(CFG)
|
||||
watches=[]
|
||||
if c.has_section("services"):
|
||||
for label,target in c["services"].items():
|
||||
target=target.strip()
|
||||
if target and not target.startswith("#"): watches.append(("service",label.upper(),target))
|
||||
if c.has_section("processes"):
|
||||
for label,target in c["processes"].items():
|
||||
target=target.strip()
|
||||
if target and not target.startswith("#"): watches.append(("process",label.upper(),target))
|
||||
return c,watches
|
||||
cfg,watches=load_cfg()
|
||||
|
||||
def cf(section,key,default,cast=float):
|
||||
try: return cast(cfg.get(section,key))
|
||||
except: return default
|
||||
|
||||
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
|
||||
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
||||
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
||||
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
||||
|
||||
CPU_HIGH=cf("alerts","cpu_high",90.0)
|
||||
CPU_RECOVER=cf("alerts","cpu_recover",75.0)
|
||||
CPU_HIGH_SEC=cf("alerts","cpu_high_seconds",30,int)
|
||||
MEM_HIGH=cf("alerts","memory_high",90.0)
|
||||
MEM_RECOVER=cf("alerts","memory_recover",85.0)
|
||||
MEM_HIGH_SEC=cf("alerts","memory_high_seconds",30,int)
|
||||
TEMP_HIGH=cf("alerts","temp_high",68.0)
|
||||
TEMP_CRIT=cf("alerts","temp_critical",75.0)
|
||||
DISK_HIGH=cf("alerts","disk_high",90.0)
|
||||
DISK_CRIT=cf("alerts","disk_critical",95.0)
|
||||
|
||||
FAN_T=[cf("fan","start",50.0),cf("fan","level2",53.0),cf("fan","level3",56.0),
|
||||
cf("fan","level4",59.0),cf("fan","max",62.0)]
|
||||
FAN_HYST=cf("fan","hysteresis",1.5)
|
||||
ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","row3_y",16,int),cf("display","row4_y",24,int)]
|
||||
DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||
LED_BRIGHT=cf("led","brightness",10,int)
|
||||
LED_WARN_ON=cf("led","warning_on_ms",300,int); LED_WARN_OFF=cf("led","warning_off_ms",2700,int)
|
||||
LED_CRIT_ON=cf("led","critical_on_ms",400,int); LED_CRIT_OFF=cf("led","critical_off_ms",1100,int)
|
||||
LED_EMERG_ON=cf("led","emergency_on_ms",400,int); LED_EMERG_OFF=cf("led","emergency_off_ms",400,int)
|
||||
|
||||
def check_watch(w):
|
||||
typ,label,target=w
|
||||
if typ=="service":
|
||||
r=subprocess.run(["systemctl","is-active","--quiet",target])
|
||||
else:
|
||||
r=subprocess.run(["pgrep","-f",target],stdout=subprocess.DEVNULL)
|
||||
return r.returncode==0
|
||||
|
||||
def alert(title,l2="",l3="",l4="",sev=1,color=(20,16,0),blink=0):
|
||||
return {"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
|
||||
"sev":sev,"color":color,"blink":blink}
|
||||
|
||||
def make_alerts(cpu,temp,mem,disk):
|
||||
global cpu_hi,mem_hi
|
||||
a=[]
|
||||
cpu_hi=cpu_hi+1 if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
|
||||
mem_hi=mem_hi+1 if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
|
||||
if cpu_hi>=CPU_HIGH_SEC:a.append(alert("CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","FOR >=30 SEC",1,(24,18,0)))
|
||||
if temp>=TEMP_CRIT:a.append(alert("TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",3,(32,0,0),2))
|
||||
elif temp>=TEMP_HIGH:a.append(alert("TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",1,(24,0,0)))
|
||||
if mem_hi>=MEM_HIGH_SEC:a.append(alert("MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","",1,(18,0,24)))
|
||||
if disk>=DISK_CRIT:a.append(alert("DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE",2,(28,6,0),2))
|
||||
elif disk>=DISK_HIGH:a.append(alert("DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE",1,(28,8,0)))
|
||||
if not link_ok:a.append(alert("NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","",2,(0,8,28),1))
|
||||
elif not net_ok:a.append(alert("INTERNET DOWN","LOCAL LINK OK",ip,"",2,(0,8,28),1))
|
||||
for w,ok in zip(watches,watch_state):
|
||||
if not ok:a.append(alert("SERVICE DOWN",w[1],"CHECK / RESTART","",2,(0,24,24),1))
|
||||
a.sort(key=lambda x:x["sev"],reverse=True)
|
||||
return a
|
||||
|
||||
def led_for(a):
|
||||
# Color = fault object. Blink frequency = severity. All alerts blink.
|
||||
if not a:
|
||||
rgb_off(); return
|
||||
if a["sev"]>=3: on_ms,off_ms=LED_EMERG_ON,LED_EMERG_OFF
|
||||
elif a["sev"]>=2: on_ms,off_ms=LED_CRIT_ON,LED_CRIT_OFF
|
||||
else: on_ms,off_ms=LED_WARN_ON,LED_WARN_OFF
|
||||
cycle=max(1,on_ms+off_ms)
|
||||
phase=int(time.monotonic()*1000)%cycle
|
||||
rgb(*a["color"]) if phase<on_ms else rgb_off()
|
||||
|
||||
|
||||
def render_home(cpu,temp,mem,disk):
|
||||
# 128x32 fixed columns. Both columns are LEFT-aligned.
|
||||
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
|
||||
im=Image.new("1",(W,H),0); pix=im.load()
|
||||
left=[
|
||||
f"CPU {cpu:.1f}%",
|
||||
f"TMP {temp:.1f}C",
|
||||
f"DSK {disk:.1f}%",
|
||||
("IP6 " if ":" in ip else "IP4 ")+ip,
|
||||
]
|
||||
right=[
|
||||
f"MEM {mem:.1f}%",
|
||||
f"FAN {FAN_NAME[fan_level]}",
|
||||
f"NET {'OK' if net_ok else 'DOWN'}",
|
||||
"",
|
||||
]
|
||||
for row in range(4):
|
||||
y=row*8
|
||||
draw_text_5x7(pix,0,y,left[row])
|
||||
if right[row]:
|
||||
draw_text_5x7(pix,72,y,right[row])
|
||||
data=[]
|
||||
for pg in range(4):
|
||||
for x in range(W):
|
||||
v=0
|
||||
for bit in range(8):
|
||||
if pix[x,pg*8+bit]: v|=1<<bit
|
||||
data.append(v)
|
||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
||||
for i in range(0,len(data),16):
|
||||
bus.write_i2c_block_data(OLED,0x40,data[i:i+16])
|
||||
|
||||
|
||||
def render_alert(a,idx,total):
|
||||
oled_show([(a["title"],f"{idx}/{total}"),
|
||||
(a["l2"],""),(a["l3"],""),(a["l4"],"")])
|
||||
|
||||
|
||||
oled_init(); rgb_off(); apply_dark_mode()
|
||||
watch_state=[check_watch(w) for w in watches]
|
||||
last_sensor=last_render=0.0
|
||||
last_net=last_watch=time.monotonic()
|
||||
latest=(0.0,0.0,0.0,0.0)
|
||||
active=[]
|
||||
|
||||
# Page state is a snapshot queue. Live sensor refreshes NEVER choose another page.
|
||||
# Each cycle is HOME + every alert captured at the cycle boundary.
|
||||
cycle_pages=["HOME"]
|
||||
cycle_pos=0
|
||||
page_started=time.monotonic()
|
||||
current_alert_key=None
|
||||
|
||||
def alert_key(a):
|
||||
# Stable identity: title + monitored object/primary detail.
|
||||
return f"{a['title']}|{a['l2']}"
|
||||
|
||||
def find_live_alert(key, live):
|
||||
for a in live:
|
||||
if alert_key(a)==key: return a
|
||||
return None
|
||||
|
||||
while running:
|
||||
try:
|
||||
now=time.monotonic()
|
||||
|
||||
|
||||
# Independent clocks: update data/state only.
|
||||
if now-last_sensor >= SENSOR_INTERVAL:
|
||||
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
||||
latest=(c,t,m,d); update_fan(t); last_sensor=now
|
||||
|
||||
if now-last_net >= NETWORK_INTERVAL:
|
||||
ip=get_ip(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False
|
||||
last_net=now
|
||||
|
||||
if now-last_watch >= SERVICE_INTERVAL:
|
||||
watch_state=[check_watch(w) for w in watches]
|
||||
last_watch=now
|
||||
|
||||
c,t,m,d=latest
|
||||
active=make_alerts(c,t,m,d)
|
||||
|
||||
# If the currently displayed alert truly disappeared, skip it immediately.
|
||||
if cycle_pages[cycle_pos]!="HOME":
|
||||
key=cycle_pages[cycle_pos]
|
||||
if find_live_alert(key,active) is None:
|
||||
cycle_pos += 1
|
||||
if cycle_pos>=len(cycle_pages):
|
||||
# End of cycle: capture a fresh snapshot.
|
||||
cycle_pages=["HOME"]+[alert_key(a) for a in active]
|
||||
cycle_pos=0
|
||||
page_started=now
|
||||
|
||||
# ONLY this timer performs normal page changes.
|
||||
if now-page_started >= PAGE_INTERVAL:
|
||||
cycle_pos += 1
|
||||
if cycle_pos>=len(cycle_pages):
|
||||
# A complete cycle ended. Snapshot all currently active alerts.
|
||||
cycle_pages=["HOME"]+[alert_key(a) for a in active]
|
||||
cycle_pos=0
|
||||
page_started=now
|
||||
|
||||
# Special case: while idle on HOME with no alert pages queued, begin a new
|
||||
# alert cycle immediately when the first alert appears. HOME keeps its
|
||||
# current 10-second slot; alerts follow it and cannot be kicked out by refresh.
|
||||
if cycle_pages==["HOME"] and active:
|
||||
cycle_pages=["HOME"]+[alert_key(a) for a in active]
|
||||
|
||||
# Render CURRENT page only. 1-second refresh does not affect cycle_pos.
|
||||
if now-last_render >= OLED_REFRESH:
|
||||
current=cycle_pages[cycle_pos]
|
||||
if current=="HOME":
|
||||
render_home(c,t,m,d)
|
||||
else:
|
||||
a=find_live_alert(current,active)
|
||||
if a:
|
||||
# Number refers to this cycle's alert slot, all using same 6x8 font.
|
||||
render_alert(a,cycle_pos,len(cycle_pages)-1)
|
||||
else:
|
||||
render_home(c,t,m,d)
|
||||
last_render=now
|
||||
|
||||
# LED follows OLED page; HOME reflects highest currently active risk.
|
||||
current=cycle_pages[cycle_pos]
|
||||
if current=="HOME":
|
||||
led_for(active[0] if active else None)
|
||||
else:
|
||||
a=find_live_alert(current,active)
|
||||
led_for(a if a else (active[0] if active else None))
|
||||
|
||||
time.sleep(0.20)
|
||||
except Exception as e:
|
||||
print("loop error:",e,flush=True); time.sleep(1)
|
||||
|
||||
rgb_off(); mcu(0x08,0x00); bus.close()
|
||||
@@ -0,0 +1,225 @@
|
||||
# =============================================================================
|
||||
# Pigway Pi Control v3.1.0 - 用户配置文件
|
||||
# =============================================================================
|
||||
#
|
||||
# 正式安装后的配置路径:
|
||||
# /etc/pigway-pi-control.conf
|
||||
#
|
||||
# 编辑:
|
||||
# sudo nano /etc/pigway-pi-control.conf
|
||||
#
|
||||
# 保存后让配置生效:
|
||||
# sudo systemctl restart pigway-pi-control
|
||||
#
|
||||
# 查看运行状态:
|
||||
# systemctl status pigway-pi-control --no-pager -l
|
||||
#
|
||||
# 查看实时日志:
|
||||
# journalctl -u pigway-pi-control -f
|
||||
#
|
||||
# 重要:
|
||||
# 1. 只建议修改本文件,不要直接修改 /usr/local/sbin/pigway-pi-control。
|
||||
# 2. “#” 开头是注释,不生效。要启用示例项,删除行首的 #。
|
||||
# 3. 数值单位和建议范围见每项上方注释。
|
||||
# 4. 配置写错导致服务无法启动时,可查看 journalctl 日志并修正本文件。
|
||||
# =============================================================================
|
||||
|
||||
|
||||
[timing]
|
||||
# 传感器采样周期,单位:秒。
|
||||
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
|
||||
# 推荐:1。通常不要低于 1。
|
||||
sensor_interval = 1
|
||||
|
||||
# OLED 当前页面刷新周期,单位:秒。
|
||||
# 这只是刷新当前页面数据,不负责切换页面。
|
||||
# 推荐:1。
|
||||
oled_refresh_interval = 1
|
||||
|
||||
# HOME / 各告警页面的轮播停留时间,单位:秒。
|
||||
# 推荐:10。觉得切换太慢可改为 5~8。
|
||||
page_interval = 10
|
||||
|
||||
# 网络状态检查周期,单位:秒。
|
||||
# 推荐:5。太短会产生不必要的网络探测。
|
||||
network_interval = 5
|
||||
|
||||
# systemd 服务和自定义进程检查周期,单位:秒。
|
||||
# 推荐:5。
|
||||
service_interval = 5
|
||||
|
||||
|
||||
[fan]
|
||||
# 风扇温控档位,单位:摄氏度。
|
||||
#
|
||||
# 默认曲线:
|
||||
# < 50°C OFF
|
||||
# 50~53°C L1
|
||||
# 53~56°C L2
|
||||
# 56~59°C L3
|
||||
# 59~62°C L4
|
||||
# >= 62°C MAX
|
||||
#
|
||||
# 如希望更安静,可适当提高阈值;如更重视散热,可降低。
|
||||
# 建议保持 start < level2 < level3 < level4 < max。
|
||||
|
||||
# 低于此温度,风扇完全停止。
|
||||
start = 50
|
||||
|
||||
# L2 起始温度;start 到 level2 之间为 L1。
|
||||
level2 = 53
|
||||
|
||||
# L3 起始温度。
|
||||
level3 = 56
|
||||
|
||||
# L4 起始温度。
|
||||
level4 = 59
|
||||
|
||||
# MAX 全速起始温度。
|
||||
max = 62
|
||||
|
||||
# 降档回差,单位:°C。
|
||||
# 防止温度在临界值附近上下波动时风扇频繁升降档。
|
||||
# 推荐:1.0~2.0。
|
||||
hysteresis = 1.5
|
||||
|
||||
|
||||
[alerts]
|
||||
# ---------------- CPU ----------------
|
||||
# CPU 使用率达到此百分比后开始累计高负载时间。
|
||||
cpu_high = 90
|
||||
|
||||
# CPU 降到此百分比以下,清除高负载累计。
|
||||
cpu_recover = 75
|
||||
|
||||
# CPU 必须持续高负载多少秒才触发告警。
|
||||
# 避免瞬时峰值造成误报。
|
||||
cpu_high_seconds = 30
|
||||
|
||||
# ---------------- 内存 ----------------
|
||||
# 内存使用率达到此百分比后开始累计。
|
||||
memory_high = 90
|
||||
|
||||
# 内存降到此百分比以下,清除累计。
|
||||
memory_recover = 85
|
||||
|
||||
# 持续多少秒才触发内存告警。
|
||||
memory_high_seconds = 30
|
||||
|
||||
# ---------------- 温度 ----------------
|
||||
# 温度普通告警阈值,单位:°C。
|
||||
temp_high = 68
|
||||
|
||||
# 温度严重告警阈值,单位:°C。
|
||||
temp_critical = 75
|
||||
|
||||
# ---------------- 磁盘 ----------------
|
||||
# 根分区使用率达到此百分比触发普通告警。
|
||||
disk_high = 90
|
||||
|
||||
# 根分区使用率达到此百分比触发严重告警。
|
||||
disk_critical = 95
|
||||
|
||||
|
||||
[led]
|
||||
# RGB 规则:
|
||||
# 正常状态 = 完全熄灭
|
||||
# 颜色 = 哪一类对象发生故障
|
||||
# 闪烁频率 = 严重程度,越严重闪得越快
|
||||
#
|
||||
# 当前颜色语义:
|
||||
# CPU负载 = 黄
|
||||
# 温度/散热 = 红
|
||||
# 内存 = 紫
|
||||
# 磁盘 = 橙
|
||||
# 网络 = 蓝
|
||||
# 服务/进程 = 青
|
||||
#
|
||||
# brightness 是 RGB 最大输出百分比(0~100)。
|
||||
# 3颗 RGB 会一起亮,卧室建议 5~15。
|
||||
brightness = 10
|
||||
|
||||
# Warning:慢闪。以下单位均为毫秒。
|
||||
warning_on_ms = 300
|
||||
warning_off_ms = 2700
|
||||
|
||||
# Critical:中速闪烁。
|
||||
critical_on_ms = 400
|
||||
critical_off_ms = 1100
|
||||
|
||||
# Emergency:快速闪烁。
|
||||
emergency_on_ms = 400
|
||||
emergency_off_ms = 400
|
||||
|
||||
|
||||
[dark_mode]
|
||||
# Raspberry Pi 4 板载 PWR / ACT LED。
|
||||
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
|
||||
#
|
||||
# off = Pigway Pi Control 启动时关闭该灯。
|
||||
# 若不希望程序干预,可改为 on。
|
||||
#
|
||||
# 注意:
|
||||
# 散热板上的 D1/D8 黄色硬件状态灯以及独立电源模块绿灯
|
||||
# 不属于已确认的软件控制接口,本程序不会尝试操作。
|
||||
pi_pwr_led = off
|
||||
pi_act_led = off
|
||||
|
||||
|
||||
[services]
|
||||
# =============================================================================
|
||||
# systemd 服务监控(推荐)
|
||||
# =============================================================================
|
||||
#
|
||||
# 格式:
|
||||
# OLED显示名称 = systemd真实unit名称
|
||||
#
|
||||
# 左侧名称建议尽量短(OLED只有128×32)。
|
||||
# 右侧必须是真实的 systemd unit。
|
||||
#
|
||||
# 如何确认服务名:
|
||||
# systemctl status ssh.service
|
||||
# systemctl list-units --type=service
|
||||
#
|
||||
# 暂停监控:
|
||||
# 在该行最前面加 #
|
||||
#
|
||||
# 新增监控:
|
||||
# 直接增加一行,例如:
|
||||
# TAILSCALE = tailscaled.service
|
||||
#
|
||||
# 修改后:
|
||||
# sudo systemctl restart pigway-pi-control
|
||||
#
|
||||
# 默认监控 SSH 和向日葵核心后台服务。
|
||||
SSH = ssh.service
|
||||
AWESUN = runawesun.service
|
||||
|
||||
# 以下仅为示例,默认不启用:
|
||||
# TAILSCALE = tailscaled.service
|
||||
# DOCKER = docker.service
|
||||
# SAMBA = smbd.service
|
||||
# NGINX = nginx.service
|
||||
|
||||
|
||||
[processes]
|
||||
# =============================================================================
|
||||
# 普通进程监控
|
||||
# =============================================================================
|
||||
#
|
||||
# 仅在程序没有 systemd service 时使用。
|
||||
# 如果存在 systemd service,优先放到上面的 [services]。
|
||||
#
|
||||
# 格式:
|
||||
# OLED显示名称 = pgrep -f 可以匹配到的进程关键字/命令行
|
||||
#
|
||||
# 示例:
|
||||
# MYAPP = /opt/myapp/server
|
||||
#
|
||||
# 可以先在终端测试:
|
||||
# pgrep -af '/opt/myapp/server'
|
||||
#
|
||||
# 有输出说明匹配成功。
|
||||
#
|
||||
# 示例默认全部注释,不会监控任何普通进程。
|
||||
# MYAPP = /opt/myapp/server
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "[1/7] 检查 Raspberry Pi I2C..."
|
||||
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; }
|
||||
|
||||
echo "[2/7] 等待 APT/DPKG..."
|
||||
for i in $(seq 1 60); do
|
||||
if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi
|
||||
[ "$i" = 60 ] && { echo "APT 锁等待超时,请稍后重试。"; exit 1; }
|
||||
echo "软件包管理器忙,等待 5 秒... ($i/60)"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "[3/7] 安装依赖..."
|
||||
apt update
|
||||
apt install -y python3 python3-pil python3-smbus2 i2c-tools
|
||||
|
||||
echo "[4/7] 检查硬件地址..."
|
||||
SCAN="$(i2cdetect -y 1)"; echo "$SCAN"
|
||||
echo "$SCAN" | grep -qi '3c' || echo "警告:未扫描到 OLED 0x3C"
|
||||
echo "$SCAN" | grep -qi '0d' || echo "警告:未扫描到控制 MCU 0x0D"
|
||||
|
||||
echo "[5/7] 安装程序与配置..."
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q '^temp-control.service'; then
|
||||
echo "检测到旧 temp-control.service,正在停止并禁用..."
|
||||
systemctl disable --now temp-control.service 2>/dev/null || true
|
||||
fi
|
||||
pkill -f '/temp_control/temp_control' 2>/dev/null || true
|
||||
mkdir -p /usr/local/share/pigway-pi-control
|
||||
install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_font_5x7.bin
|
||||
|
||||
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
|
||||
|
||||
NEW=/tmp/pigway-pi-control.conf.new
|
||||
cp config/pigway-pi-control.conf "$NEW"
|
||||
if [ -f /etc/pigway-pi-control.conf ]; then
|
||||
cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
# Preserve user's [services] and [processes] sections; adopt new defaults elsewhere.
|
||||
python3 - "$NEW" /etc/pigway-pi-control.conf <<'PY'
|
||||
import configparser,sys
|
||||
newf,oldf=sys.argv[1:]
|
||||
n=configparser.ConfigParser(); n.optionxform=str; n.read(newf)
|
||||
o=configparser.ConfigParser(); o.optionxform=str; o.read(oldf)
|
||||
for sec in ("services","processes"):
|
||||
if o.has_section(sec):
|
||||
if n.has_section(sec): n.remove_section(sec)
|
||||
n.add_section(sec)
|
||||
for k,v in o.items(sec): n.set(sec,k,v)
|
||||
with open(newf,"w") as f:n.write(f)
|
||||
PY
|
||||
fi
|
||||
install -m 0644 "$NEW" /etc/pigway-pi-control.conf
|
||||
rm -f "$NEW"
|
||||
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service
|
||||
|
||||
echo "[6/7] 启用/重启服务..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable pigway-pi-control.service >/dev/null
|
||||
systemctl restart pigway-pi-control.service
|
||||
|
||||
echo "[7/8] 安装后自检..."
|
||||
FAIL=0
|
||||
check() { if eval "$2"; then printf " %-30s PASS\n" "$1"; else printf " %-30s FAIL\n" "$1"; FAIL=1; fi; }
|
||||
check "Version 3.1.0" "grep -q 'APP_VERSION=\"3.1.0\"' /usr/local/sbin/pigway-pi-control"
|
||||
check "OLED physical 128x32" "grep -q 'W,H=128,32' /usr/local/sbin/pigway-pi-control"
|
||||
check "OLED MUX 0x1F" "grep -q '0xA8,0x1F' /usr/local/sbin/pigway-pi-control"
|
||||
check "OLED pages 0..3" "grep -q 'cmd(0x22);cmd(0);cmd(3)' /usr/local/sbin/pigway-pi-control"
|
||||
check "UI CPU/TMP/DSK" "grep -q 'f\"TMP {temp:.1f}C\"' /usr/local/sbin/pigway-pi-control"
|
||||
check "UI IP4/IP6" "grep -q 'IP6 .*IP4 ' /usr/local/sbin/pigway-pi-control"
|
||||
check "UI right column x=72" "grep -q \"draw_text_5x7(pix,72,y,right\\[row\\])\" /usr/local/sbin/pigway-pi-control"
|
||||
check "Legacy service stopped" "! systemctl is-active --quiet temp-control.service"
|
||||
check "Service running" "systemctl is-active --quiet pigway-pi-control.service"
|
||||
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
|
||||
echo "[8/8] 状态..."
|
||||
|
||||
|
||||
systemctl --no-pager --full status pigway-pi-control.service || true
|
||||
echo
|
||||
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
|
||||
echo "配置:/etc/pigway-pi-control.conf"
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Pigway Raspberry Pi OLED/Fan/RGB Monitor
|
||||
After=multi-user.target network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/sbin/pigway-pi-control
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
systemctl disable --now pigway-pi-control.service 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/pigway-pi-control.service
|
||||
rm -f /usr/local/sbin/pigway-pi-control
|
||||
rm -rf /usr/local/share/pigway-pi-control
|
||||
systemctl daemon-reload
|
||||
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
|
||||
echo "如确定不要配置,可手工:sudo rm /etc/pigway-pi-control.conf"
|
||||
Reference in New Issue
Block a user