Compare commits

..
10 changed files with 542 additions and 56 deletions
+62 -12
View File
@@ -1,8 +1,8 @@
# Pigway Pi Control v3.5.0 System Health
# PIGWay Pi Control v3.6.0
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
v3.5.0 完成本地检测、事件状态、日志、诊断、显示仲裁、可配置 RGB 告警和关机收尾,为后续轻量 API/Web 管理界面提供稳定基础。
v3.6.0 在本地硬件监控、显示仲裁和可配置 RGB 告警基础上,增加轻量状态/日志/配置 API 与同源 Web 管理界面。
## 已有核心功能
@@ -11,7 +11,7 @@ v3.5.0 完成本地检测、事件状态、日志、诊断、显示仲裁、可
- Wi-Fi RSSI / Ethernet 链路速率
- 当前默认路由与 IP 同步切换
- 风扇自动温控、回差防抖
- RGB:内置灯效的颜色与速度表示严重程度
- RGB:自定义颜色表示故障对象,频闪速度表示严重程度
- systemd 服务与普通进程监控
- Raspberry Pi PWR / ACT 运行时 Dark Mode
@@ -71,9 +71,11 @@ CHECK STORAGE
每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个最高优先级 owner;同一 ID 更新原请求而不重复入队,恢复时删除请求,同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即抢占一次 OLED。告警页显示时 RGB 同步表达该页对象;回到主页时 RGB 保持最高优先级 active owner,全部恢复后才 OFF。历史 power flags 只进入 diagnose 和一次性 Journal 记录。
### 5. RGB 告警引擎
### 5. RGB 正常状态与告警引擎
RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭;没有 active alert 时 OFF。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。
没有 active alert 时,RGB 默认全灭。`[led] normal_mode` 可改为自定义颜色常亮/闪烁,或 MCU 已确认的流水、呼吸、跑马、彩虹和炫彩效果。常亮与闪烁使用 0..255 的自定义 R/G/B;内置流水/呼吸使用 MCU 固定的红、绿、蓝、黄、紫、青、白七色和慢/中/快三档速度,跑马/彩虹/炫彩由 MCU 自行配色。内置效果只在模式切换时按 speed → color → effect 的实机校准顺序写一次寄存器,不循环刷 I²C。
任何 active alert 都会立即抢占正常效果。RGB 告警模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高优先级 active owner。颜色表示故障对象:CPU负载、频率、throttling、温度与散热相关为红色,供电/欠压为橙色,内存为紫色,存储为白色,网络/Wi-Fi 为蓝色,服务/进程为青色。频率表示严重等级:默认 Warning 为1500ms亮/1500ms灭,Critical 为750ms亮/750ms灭,Emergency 为300ms亮/300ms灭。`[led]` 可分别配置六类对象的 R/G/B 值,以及三个严重等级的亮灯和灭灯时长,非法颜色值会限制到0..255,间隔最小为50ms。每次点亮使用 v3.2.0 已验证的完整 selector/R/G/B 写入顺序,只在亮/灭边沿写 MCU,不做高频软件 PWM。相邻页面 RGB mode 相同时不关闭、不重启节奏。动画不写循环日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。
### 6. 结构化事件日志
@@ -82,7 +84,7 @@ RGB 模式由当前 OLED 告警页决定;OLED 位于主页时则使用最高
只在 **状态发生变化** 时记录关键事件,避免每秒刷屏。例如:
```text
level=INFO event=START version=3.3.0
level=INFO event=START version=3.6.0
level=INFO event=NET_SWITCH old_if=eth0 new_if=wlan0 ...
level=WARN event=NETWORK_DOWN ...
level=INFO event=NETWORK_RECOVERED ...
@@ -136,6 +138,50 @@ RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认静态自定义颜
Pi 红色 PWR 灯关机后恢复硬件默认亮起的行为不强行修改,可作为“系统已关机但 UPS 仍供电”的直观提示。
## v3.6.0 API 与 Web
安装后访问:
```text
http://树莓派IP:6001/
```
Web 与 JSON API 由独立的 `pigway-pi-control-api.service` 提供。API 服务不访问 I²C;硬件 Agent 每秒原子更新 `/run/pigway-pi-control/status.json`,API 只读取该快照。默认监听地址和端口可在 `[api]` 中修改:
```ini
[api]
bind = 0.0.0.0
port = 6001
log_limit = 200
```
只读接口:
```text
GET /api/v1/health
GET /api/v1/status
GET /api/v1/logs?limit=200
GET /api/v1/config
```
配置写入接口:
```text
PUT /api/v1/config
Authorization: Bearer <token>
Content-Type: application/json
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
```
读取 token:
```bash
sudo cat /etc/pigway-pi-control-api.token
```
配置更新仅允许 `[services]`、`[processes]`、`[alerts]`、`[fan]`、`[led]`、`[timing]`、`[dark_mode]`、`[display]` 和 `[api]`。每次写入先生成时间戳备份,再原地更新目标键并重启硬件 Agent;修改 `[api]` 时 API 服务也会自动重启。
## 用户配置
正式配置只有一个:
@@ -162,24 +208,28 @@ sudo ./install.sh
安装器会:
- 安装必要依赖
- 检查 I²C
- 禁用旧 `temp-control.service`
- 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用
- 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务
- 确认需要多个进程共用 I²C 时,可用 `sudo ./install.sh --force-i2c-conflict` 强制继续;冲突进程仍由用户自行管理
- 安装字体、程序、配置和 systemd unit
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅向 `[led]` 补入缺失的 RGB 颜色与频闪选项,已有用户值优先
- 启动服务
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先
- 生成只读 root token,安装 API/Web 文件
- 启动硬件 Agent 与 API/Web 服务
- 执行版本、OLED、UI、P0健康监控和诊断入口自检
## 常用命令
```bash
systemctl status pigway-pi-control --no-pager -l
journalctl -u pigway-pi-control-api -f
journalctl -u pigway-pi-control -f
sudo systemctl restart pigway-pi-control
sudo systemctl restart pigway-pi-control-api
sudo /usr/local/sbin/pigway-pi-control --diagnose
```
## 版本路线
- v3.2.0:主网络接口 / Wi-Fi RSSI / ETH速率 / 双网切换
- **v3.5.0:System Health、显示仲裁与可配置 RGB 告警**
- 后续:只读 `/api/v1/...` Agent API,再由 NAS 统一汇总多台设备
- v3.5.0:System Health、显示仲裁与可配置 RGB 告警
- **v3.6.0:轻量状态/日志/配置 API 与同源 Web 管理界面**
+9 -6
View File
@@ -1,7 +1,10 @@
dfe901d5f7e3a8ef897203c38cf9e4d47eca63e0d718f74b99102c874edfc3b3 README.md
a182abe8c1f188b9e9b47fdc24802fedd6a569b462a85254185521fe68936a76 README.md
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
43aca7bda6e8edb59734e06ccd373a9426c73fbf359727a906b6e2ca405fc344 app/pigway_pi_control.py
02e67b147ae68378bed1dcb88245b636c72fba3dabe4c119aab61219eee89995 config/pigway-pi-control.conf
d84774f2fe2bfe380b7b29d83d42ddc17938fb03f78a746df07fc2e49f635e64 install.sh
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service
a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh
d71d949643d31461f2e62ded3818e1a0f1711d1420756ad0cc32eb04cde0fad3 app/pigway_pi_control.py
3a13b4e8f9d306e77f82eba78b78fe9b1932488884d6f3f976754bfa94f0153d app/pigway_pi_control_api.py
75fa662ed21138a8e394de83e46281dab039433cc0d7a8f787e1a049f4b47ec8 config/pigway-pi-control.conf
f34eac1df180a3da3a6998d650c06435fd89728c10b090f6162df82e53986dd0 install.sh
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
53a1cbbef49d1300c5d79ab2dda9d57d6743fe8d34cbae60d0c81d7d6e6f561c web/index.html
+90 -7
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env python3
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json
from pathlib import Path
APP_VERSION="3.5.0"
APP_VERSION="3.6.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"
STATUS_PATH="/run/pigway-pi-control/status.json"
running=True
def stop(*_):
@@ -252,6 +253,20 @@ RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int))
WARNING_FLASH=(max(50,cf("led","warning_flash_on_ms",1500,int)),max(50,cf("led","warning_flash_off_ms",1500,int)))
CRITICAL_FLASH=(max(50,cf("led","critical_flash_on_ms",750,int)),max(50,cf("led","critical_flash_off_ms",750,int)))
EMERGENCY_FLASH=(max(50,cf("led","emergency_flash_on_ms",300,int)),max(50,cf("led","emergency_flash_off_ms",300,int)))
NORMAL_MODE=cf("led","normal_mode","off",str).strip().lower()
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
if NORMAL_MODE not in NORMAL_MODES: NORMAL_MODE="off"
NORMAL_COLOR=tuple(max(0,min(255,cf("led",f"normal_{channel}",value,int)))
for channel,value in zip(("r","g","b"),(0,0,0)))
NORMAL_FLASH=(max(50,cf("led","normal_flash_on_ms",1000,int)),
max(50,cf("led","normal_flash_off_ms",1000,int)))
NORMAL_EFFECT_SPEED=max(1,min(3,cf("led","normal_effect_speed",2,int)))
NORMAL_EFFECT_COLOR=cf("led","normal_effect_color","green",str).strip().lower()
RGB_EFFECT_COLORS={"red":0,"green":1,"blue":2,"yellow":3,"purple":4,"cyan":5,"white":6}
if NORMAL_EFFECT_COLOR not in RGB_EFFECT_COLORS: NORMAL_EFFECT_COLOR="green"
RGB_EFFECTS={"flow":0,"breathe":1,"marquee":2,"rainbow":3,"colorful":4}
NORMAL_RGB_MODE={"off":"OFF","solid":"NORMAL_SOLID","flash":"NORMAL_FLASH",
**{name:f"NORMAL_{name.upper()}" for name in RGB_EFFECTS}}[NORMAL_MODE]
def rgb_cfg(name,default):
return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int)))
@@ -440,6 +455,29 @@ def rgb_flash(mode,on):
mcu(0x03,channels[2])
_last_rgb=state
def rgb_static(channels,state_name):
global _last_rgb
state=(state_name,channels)
if _last_rgb==state: return
mcu(0x07,0x00); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x01,channels[0]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x02,channels[1]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x03,channels[2])
_last_rgb=state
def rgb_normal_effect(mode):
global _last_rgb
effect_name=mode.removeprefix("NORMAL_").lower()
state=("effect",effect_name,NORMAL_EFFECT_SPEED,NORMAL_EFFECT_COLOR)
if _last_rgb==state: return
# Hardware-calibrated order: speed and color are parameters; the final
# effect register write commits and starts the selected MCU animation.
mcu(0x05,NORMAL_EFFECT_SPEED); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x06,RGB_EFFECT_COLORS[NORMAL_EFFECT_COLOR]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
mcu(0x04,RGB_EFFECTS[effect_name])
_last_rgb=state
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.
@@ -484,7 +522,7 @@ class DisplayController:
now=time.monotonic()
self.lock=threading.RLock()
self.requests={"NORMAL_HOME":{
"id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":"OFF",
"id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":NORMAL_RGB_MODE,
"title":"","l2":"","l3":"","l4":"","active":True,
"first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)},
}}
@@ -616,6 +654,17 @@ class DisplayController:
def owner_snapshot(self):
with self.lock: return dict(self.requests[self.current_owner_id])
def status_snapshot(self):
with self.lock:
alerts=self._ordered_alerts_locked()
return {
"owner":self.current_owner_id,
"oled_page":self.current_oled_page_id,
"rgb_mode":self.current_rgb_mode,
"alerts":[{k:a[k] for k in ("id","priority","severity","title","l2","l3","l4","first_seen","last_changed")}
for a in alerts],
}
def _rgb_worker(self):
previous_error=None; applied_mode="OFF"; applied_flash=False; animation_started=time.monotonic()
while not self.stop_event.is_set():
@@ -625,18 +674,27 @@ class DisplayController:
if wanted_mode!=applied_mode:
with bus_lock:
if wanted_mode=="OFF": rgb_off()
elif wanted_flash: rgb_flash(wanted_mode,True)
elif wanted_mode=="NORMAL_SOLID": rgb_static(NORMAL_COLOR,"normal_solid")
elif wanted_mode=="NORMAL_FLASH": rgb_static(NORMAL_COLOR,"normal_flash_on")
elif wanted_mode.startswith("NORMAL_"): rgb_normal_effect(wanted_mode)
elif wanted_flash:
rgb_off()
rgb_flash(wanted_mode,True)
else: raise ValueError(f"unsupported RGB mode: {wanted_mode}")
log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode)
applied_mode=wanted_mode; animation_started=time.monotonic()
applied_flash=applied_mode.endswith("_FLASH")
if applied_flash:
elapsed_ms=(time.monotonic()-animation_started)*1000.0
if "_EMERGENCY_" in applied_mode: on_ms,off_ms=EMERGENCY_FLASH
if applied_mode=="NORMAL_FLASH": on_ms,off_ms=NORMAL_FLASH
elif "_EMERGENCY_" in applied_mode: on_ms,off_ms=EMERGENCY_FLASH
elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH
else: on_ms,off_ms=WARNING_FLASH
on=elapsed_ms%(on_ms+off_ms)<on_ms
with bus_lock: rgb_flash(applied_mode,on)
with bus_lock:
if applied_mode=="NORMAL_FLASH":
rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off()
else: rgb_flash(applied_mode,on)
if previous_error is not None: log("INFO","RGB_RECOVERED")
previous_error=None
except Exception as e:
@@ -660,6 +718,29 @@ def cleanup():
for name,action in (("fan",lambda:mcu(0x08,0x00)),("bus_close",bus.close)):
try: action()
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
try: Path(STATUS_PATH).unlink(missing_ok=True)
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
def write_status(cpu,temp,mem,disk):
display_state=display.status_snapshot()
state={
"version":APP_VERSION,"timestamp":int(time.time()),"uptime_seconds":uptime_seconds(),
"system":{"load_average":load_average(),"cpu_percent":round(cpu,1),
"cpu_freq_mhz":round(cpu_freq_mhz()),"temperature_c":round(temp,1),
"memory_percent":round(mem,1),"disk_percent":round(disk,1),
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
"power":health,
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
"ip_label":ip_label,"ip":ip,"link":bool(link_ok),"internet":bool(net_ok)},
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
for w,ok in zip(watches,watch_state)],
"display":display_state,
"agent":{"loop_errors":agent["loop_errors"],"i2c_errors":agent["i2c_errors"]},
}
path=Path(STATUS_PATH); path.parent.mkdir(mode=0o755,parents=True,exist_ok=True)
temporary=path.with_suffix(".tmp")
temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":")))
temporary.chmod(0o644); temporary.replace(path)
# Dispatch only after all shared checks and configuration have been initialized.
parser=argparse.ArgumentParser()
@@ -668,7 +749,7 @@ args=parser.parse_args()
if args.diagnose: raise SystemExit(diagnose())
watch_state=[]
last_sensor=last_render=0.0
last_sensor=last_render=last_status=0.0
last_net=last_watch=last_health=0.0
health={}; fs_readonly=False
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0,"i2c_errors":0}
@@ -731,6 +812,8 @@ try:
if now-last_render >= OLED_REFRESH:
display.refresh()
last_render=now
if now-last_status >= 1.0:
write_status(c,t,m,d); last_status=now
time.sleep(0.20)
except Exception as e:
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs,urlparse
APP_VERSION="3.6.0"
CFG=Path("/etc/pigway-pi-control.conf")
STATUS=Path("/run/pigway-pi-control/status.json")
TOKEN=Path("/etc/pigway-pi-control-api.token")
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
ALLOWED_SECTIONS={"services","processes","alerts","fan","led","timing","dark_mode","display","api"}
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency|normal)_flash_(on|off)_ms$")
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
def load_cfg():
cfg=configparser.ConfigParser(); cfg.read(CFG)
return cfg
def api_setting(key,default,cast=str):
try:return cast(load_cfg().get("api",key))
except Exception:return default
def config_json():
cfg=load_cfg()
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
def validate_value(section,key,value,existing):
if section not in {"services","processes"} and key not in existing.get(section,set()):
raise ValueError(f"unsupported option: {section}.{key}")
if RGB_KEY_RE.fullmatch(key):
number=int(value)
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
elif FLASH_KEY_RE.fullmatch(key):
number=int(value)
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000")
elif section=="led" and key=="normal_mode":
if value.lower() not in NORMAL_MODES: raise ValueError("normal_mode must be off, solid, flash, flow, breathe, marquee, rainbow or colorful")
elif section=="led" and key=="normal_effect_speed":
if int(value) not in (1,2,3): raise ValueError("normal_effect_speed must be 1, 2 or 3")
elif section=="led" and key=="normal_effect_color":
if value.lower() not in EFFECT_COLORS: raise ValueError("normal_effect_color must be red, green, blue, yellow, purple, cyan or white")
elif section=="api" and key=="port":
number=int(value)
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535")
elif section=="api" and key=="log_limit":
number=int(value)
if not 1<=number<=1000: raise ValueError("api.log_limit must be 1..1000")
elif section=="api" and key=="bind":
ipaddress.ip_address(value)
def update_ini(updates):
if not isinstance(updates,dict): raise ValueError("updates must be an object")
text=CFG.read_text()
current=load_cfg(); existing={section:set(current.options(section)) for section in current.sections()}
for section,values in updates.items():
if section not in ALLOWED_SECTIONS or not isinstance(values,dict):
raise ValueError(f"unsupported section: {section}")
for key,value in values.items():
if not NAME_RE.fullmatch(str(key)): raise ValueError(f"invalid key: {key}")
if value is None:
raise ValueError(f"null is not allowed: {section}.{key}")
value=str(value).strip()
if not value or len(value)>256 or any(c in value for c in "\r\n\x00"):
raise ValueError(f"invalid value: {section}.{key}")
validate_value(section,str(key),value,existing)
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
if not section_match:
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
continue
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
chunk=text[section_match.end():end]
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
if key_match:
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
replacement=key_match.group(1)+value
text=text[:start]+replacement+text[stop:]
else:
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
parsed=configparser.ConfigParser(); parsed.read_string(text)
backup=CFG.with_name(f"{CFG.name}.bak.api-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(CFG,backup)
temporary=CFG.with_suffix(".tmp")
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup)
def journal(limit):
result=subprocess.run(["journalctl","-u","pigway-pi-control.service","-n",str(limit),
"--no-pager","-o","json"],capture_output=True,text=True,timeout=5)
if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed")
rows=[]
for line in result.stdout.splitlines():
try:
item=json.loads(line)
rows.append({"timestamp":item.get("__REALTIME_TIMESTAMP"),"priority":item.get("PRIORITY"),
"message":item.get("MESSAGE","")})
except json.JSONDecodeError: pass
return rows
class Handler(BaseHTTPRequestHandler):
server_version="PIGWayAPI/3.6"
def log_message(self,fmt,*args):
return
def send_json(self,status,payload):
data=json.dumps(payload,ensure_ascii=False,separators=(",",":")).encode()
self.send_response(status); self.send_header("Content-Type","application/json; charset=utf-8")
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data)
def security_headers(self):
self.send_header("Cache-Control","no-store")
self.send_header("X-Content-Type-Options","nosniff")
self.send_header("X-Frame-Options","DENY")
self.send_header("Content-Security-Policy","default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
def authorized(self):
try: expected=TOKEN.read_text().strip()
except OSError: return False
supplied=self.headers.get("Authorization","")
return supplied.startswith("Bearer ") and secrets.compare_digest(supplied[7:],expected)
def read_json(self):
length=int(self.headers.get("Content-Length","0"))
if length<=0 or length>65536: raise ValueError("invalid request size")
return json.loads(self.rfile.read(length))
def do_GET(self):
parsed=urlparse(self.path)
try:
if parsed.path=="/":
data=WEB.read_bytes(); self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
self.send_header("Content-Length",str(len(data))); self.security_headers(); self.end_headers(); self.wfile.write(data); return
if parsed.path=="/api/v1/health":
self.send_json(200,{"ok":True,"version":APP_VERSION,"status_available":STATUS.exists()}); return
if parsed.path=="/api/v1/status":
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
self.send_json(200,json.loads(STATUS.read_text())); return
if parsed.path=="/api/v1/logs":
limit=min(max(int(parse_qs(parsed.query).get("limit",[api_setting("log_limit",200,int)])[0]),1),1000)
self.send_json(200,{"logs":journal(limit)}); return
if parsed.path=="/api/v1/config":
self.send_json(200,{"config":config_json(),"write_requires_token":True}); return
self.send_json(404,{"error":"not found"})
except Exception as e: self.send_json(500,{"error":str(e)})
def do_PUT(self):
if urlparse(self.path).path!="/api/v1/config": self.send_json(404,{"error":"not found"}); return
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
try:
body=self.read_json(); updates=body.get("updates")
backup=update_ini(updates)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
restart_api="api" in updates
print(f"CONFIG_UPDATED sections={','.join(sorted(updates))} backup={backup}",flush=True)
self.send_json(200,{"ok":True,"backup":backup,"api_restart":restart_api})
if restart_api: threading.Timer(0.2,self.server.shutdown).start()
except (ValueError,json.JSONDecodeError) as e: self.send_json(400,{"error":str(e)})
except Exception as e: self.send_json(500,{"error":str(e)})
def main():
host=api_setting("bind","0.0.0.0")
port=api_setting("port",6001,int)
server=ThreadingHTTPServer((host,port),Handler)
print(f"API_START version={APP_VERSION} bind={host} port={port}",flush=True)
try: server.serve_forever()
finally: server.server_close()
if __name__=="__main__": main()
+38 -4
View File
@@ -1,5 +1,5 @@
# =============================================================================
# Pigway Pi Control v3.5.0 - 用户配置文件
# PIGWay Pi Control v3.6.0 - 用户配置文件
# =============================================================================
#
# 正式安装后的配置路径:
@@ -25,6 +25,14 @@
# =============================================================================
[api]
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
bind = 0.0.0.0
port = 6001
log_limit = 200
[timing]
# 传感器采样周期,单位:秒。
# CPU / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。
@@ -139,6 +147,34 @@ wifi_weak_seconds = 30
[led]
# 正常运行、没有任何 active alert 时的 RGB 表现:
# off = 全灭(默认,最安静且 I2C 写入最少)
# solid = 三灯使用 normal_r/g/b 自定义颜色常亮
# flash = 三灯使用 normal_r/g/b 和 normal_flash_*_ms 自定义颜色闪烁
# flow = MCU 内置流水灯;使用有限颜色和速度
# breathe = MCU 内置呼吸灯;使用有限颜色和速度
# marquee = MCU 内置跑马灯;MCU 自行决定颜色变化
# rainbow = MCU 内置彩虹灯;MCU 自行决定颜色变化
# colorful = MCU 内置炫彩灯;MCU 自行决定颜色变化
# 任何 active alert 都会立即抢占正常效果;全部恢复后才重新应用这里的模式。
normal_mode = off
# solid / flash 自定义颜色,三通道范围均为 0..255。
normal_r = 0
normal_g = 0
normal_b = 0
# flash 亮灯/灭灯时间,单位毫秒;范围 50..60000。
normal_flash_on_ms = 1000
normal_flash_off_ms = 1000
# MCU 内置效果速度:1=慢,2=中(默认),3=快。
normal_effect_speed = 2
# flow / breathe 可使用的 MCU 内置有限颜色:
# red / green / blue / yellow / purple / cyan / white
# marquee / rainbow / colorful 会忽略该颜色值。
normal_effect_color = green
# DisplayController 按集中定义的 priority 仲裁 owner。告警页显示时 RGB 同步显示
# 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级:
# CPU/频率/温度/散热 = 红色
@@ -175,8 +211,6 @@ critical_flash_on_ms = 750
critical_flash_off_ms = 750
emergency_flash_on_ms = 300
emergency_flash_off_ms = 300
# NORMAL_HOME 为 owner 时 RGB OFF。
#
# 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序,
# 只在亮/灭边沿访问 MCU,不做高频软件 PWM。
write_delay_ms = 10
@@ -191,7 +225,7 @@ write_delay_ms = 10
# Raspberry Pi 4 板载 PWR / ACT LED。
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。
#
# off = Pigway Pi Control 启动时关闭该灯。
# off = PIGWay Pi Control 启动时关闭该灯。
# 若不希望程序干预,可改为 on。
#
# 注意:
+106 -25
View File
@@ -2,7 +2,20 @@
set -euo pipefail
cd "$(dirname "$0")"
for required in app/pigway_pi_control.py app/oled_font_5x7.bin config/pigway-pi-control.conf systemd/pigway-pi-control.service; do
FORCE_I2C_CONFLICT=0
for arg in "$@"; do
case "$arg" in
--force-i2c-conflict) FORCE_I2C_CONFLICT=1 ;;
-h|--help)
echo "Usage: sudo ./install.sh [--force-i2c-conflict]"
echo " --force-i2c-conflict Continue even when another process owns /dev/i2c-1."
exit 0
;;
*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
esac
done
for required in app/pigway_pi_control.py app/pigway_pi_control_api.py app/oled_font_5x7.bin web/index.html config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
if [ ! -f "$required" ]; then
echo "ERROR: required file missing: $required" >&2
exit 1
@@ -12,6 +25,41 @@ done
echo "[1/8] 检查 Raspberry Pi I2C..."
[ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; }
# A running copy of this project is expected during an upgrade. Any other
# process holding the shared bus could race OLED/MCU writes, so report it and
# let the user decide how that process should be managed.
i2c_conflict_pids() {
local allowed_pid pid
allowed_pid="$(systemctl show -p MainPID --value pigway-pi-control.service 2>/dev/null || true)"
for pid in $(fuser /dev/i2c-1 2>/dev/null || true); do
case "$pid" in
''|*[!0-9]*) continue ;;
esac
[ "$pid" = "$$" ] && continue
[ "$pid" = "$allowed_pid" ] && continue
kill -0 "$pid" 2>/dev/null && echo "$pid"
done
}
I2C_CONFLICTS="$(i2c_conflict_pids)"
if [ -n "$I2C_CONFLICTS" ]; then
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
echo "WARNING: /dev/i2c-1 is already in use by another process:" >&2
else
echo "ERROR: /dev/i2c-1 is already in use by another process:" >&2
fi
while read -r pid; do
ps -p "$pid" -o pid=,user=,comm=,args= >&2 || true
done <<< "$I2C_CONFLICTS"
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
echo "WARNING: continuing because --force-i2c-conflict was specified; no conflicting process will be stopped." >&2
else
echo "Stop or reconfigure the conflicting OLED/LED controller, then run the installer again." >&2
echo "If concurrent control is intentional, rerun with --force-i2c-conflict." >&2
exit 1
fi
fi
echo "[2/8] 等待 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
@@ -30,33 +78,36 @@ echo "$SCAN" | grep -qi '3c' || echo "警告:未扫描到 OLED 0x3C"
echo "$SCAN" | grep -qi '0d' || echo "警告:未扫描到控制 MCU 0x0D"
echo "[5/8] 安装程序与配置..."
if systemctl list-unit-files 2>/dev/null | grep '^temp-control.service' >/dev/null; 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
mkdir -p /usr/local/share/pigway-pi-control/web
install -m 0644 web/index.html /usr/local/share/pigway-pi-control/web/index.html
install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control
install -m 0755 app/pigway_pi_control_api.py /usr/local/sbin/pigway-pi-control-api
# Keep the complete existing configuration, including comments and custom sections.
# New options use runtime defaults until the user chooses to add them.
# The merge below adds only missing documented options.
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)"
else
install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf
fi
# Preserve the existing file verbatim while exposing newly configurable RGB
# defaults. Missing keys are inserted into [led]; existing user values win.
# Preserve the existing file while exposing new defaults. Existing values win.
python3 - /etc/pigway-pi-control.conf <<'PY'
import re,sys
from pathlib import Path
path=Path(sys.argv[1])
text=path.read_text()
defaults=(
sections={
"led":(
("normal_mode","off"),
("normal_r",0),("normal_g",0),("normal_b",0),
("normal_flash_on_ms",1000),("normal_flash_off_ms",1000),
("normal_effect_speed",2),("normal_effect_color","green"),
("write_delay_ms",10),
("cpu_r",255),("cpu_g",0),("cpu_b",0),
("power_r",255),("power_g",40),("power_b",0),
("memory_r",255),("memory_g",0),("memory_b",255),
@@ -66,42 +117,69 @@ defaults=(
("warning_flash_on_ms",1500),("warning_flash_off_ms",1500),
("critical_flash_on_ms",750),("critical_flash_off_ms",750),
("emergency_flash_on_ms",300),("emergency_flash_off_ms",300),
)
missing=[(key,value) for key,value in defaults
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",text)]
if missing:
block="\n# RGB object colors (0..255) and flash timing (milliseconds).\n"
block+="".join(f"{key} = {value}\n" for key,value in missing)
match=re.search(r"(?m)^\[led\]\s*$",text)
),
"api":(("bind","0.0.0.0"),("port",6001),("log_limit",200)),
}
for section,defaults in sections.items():
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text)
if match:
next_section=re.search(r"(?m)^\[.+\]\s*$",text[match.end():])
pos=match.end()+(next_section.start() if next_section else len(text)-match.end())
text=text[:pos].rstrip()+"\n"+block+"\n"+text[pos:].lstrip("\n")
end=match.end()+(next_section.start() if next_section else len(text)-match.end())
chunk=text[match.end():end]
else:
text=text.rstrip()+"\n\n[led]\n"+block
path.write_text(text)
text=text.rstrip()+f"\n\n[{section}]\n"
match=re.search(rf"(?m)^\[{re.escape(section)}\]\s*$",text)
end=len(text); chunk=""
missing=[(key,value) for key,value in defaults
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)]
if missing:
note="# Added by installer; existing user values are preserved.\n"
if section=="led" and any(key.startswith("normal_") for key,_ in missing):
note+=("# Normal RGB: normal_mode = off/solid/flash/flow/breathe/marquee/rainbow/colorful.\n"
"# normal_r/g/b = 0..255; normal_flash_*_ms = 50..60000; effect speed = 1..3.\n"
"# Effect color = red/green/blue/yellow/purple/cyan/white (flow/breathe only).\n")
block=note+"".join(f"{key} = {value}\n" for key,value in missing)
text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n")
path.write_text(text)
PY
if [ ! -s /etc/pigway-pi-control-api.token ]; then
umask 077
python3 - <<'PY' > /etc/pigway-pi-control-api.token
import secrets
print(secrets.token_urlsafe(32))
PY
fi
chmod 0600 /etc/pigway-pi-control-api.token
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/pigway-pi-control-api.service
echo "[6/8] 启用/重启服务..."
systemctl daemon-reload
systemctl enable pigway-pi-control.service >/dev/null
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
systemctl restart pigway-pi-control.service
systemctl restart pigway-pi-control-api.service
echo "[7/8] 安装后自检..."
FAIL=0
check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; }
APP=/usr/local/sbin/pigway-pi-control
check "Version 3.5.0" grep -Fq 'APP_VERSION="3.5.0"' "$APP"
check "Version 3.6.0" grep -Fq 'APP_VERSION="3.6.0"' "$APP"
check "OLED physical 128x32" grep -Fq 'W,H=128,32' "$APP"
check "OLED MUX 0x1F" grep -Fq '0xA8,0x1F' "$APP"
check "OLED pages 0..3" grep -Fq 'cmd(0x22);cmd(0);cmd(3)' "$APP"
check "UI CPU/TMP/DSK" grep -Fq 'f"TMP {temp:.1f}C"' "$APP"
check "UI IP4/IP6" grep -Fq 'ip_label' "$APP"
check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP"
legacy_stopped() { ! systemctl is-active --quiet temp-control.service; }
check "Legacy service stopped" legacy_stopped
if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
printf " %-30s WARN (forced)\n" "I2C owner conflict"
else
no_other_i2c_owner() { [ -z "$(i2c_conflict_pids)" ]; }
check "No other I2C owner" no_other_i2c_owner
fi
check "Service running" systemctl is-active --quiet pigway-pi-control.service
check "API/Web service running" systemctl is-active --quiet pigway-pi-control-api.service
check "API/Web version 3.6.0" grep -Fq 'APP_VERSION="3.6.0"' /usr/local/sbin/pigway-pi-control-api
check "Web UI installed" test -s /usr/local/share/pigway-pi-control/web/index.html
check "P0 health monitor" grep -Fq 'throttled_state' "$APP"
check "Diagnostic command" grep -Fq -- '--diagnose' "$APP"
if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi
@@ -109,6 +187,9 @@ echo "[8/8] 状态..."
systemctl --no-pager --full status pigway-pi-control.service || true
systemctl --no-pager --full status pigway-pi-control-api.service || true
echo
echo "完成。程序:/usr/local/sbin/pigway-pi-control"
echo "配置:/etc/pigway-pi-control.conf"
echo "Web/API:http://$(hostname -I | awk '{print $1}'):$(awk -F= '/^port[[:space:]]*=/{gsub(/[[:space:]]/,"",$2);print $2;exit}' /etc/pigway-pi-control.conf)"
echo "配置写入 token:sudo cat /etc/pigway-pi-control-api.token"
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=PIGWay Pi Control API and Web
After=network.target pigway-pi-control.service
Wants=network.target pigway-pi-control.service
[Service]
Type=simple
ExecStart=/usr/local/sbin/pigway-pi-control-api
Restart=always
RestartSec=2
User=root
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Pigway Raspberry Pi OLED/Fan/RGB Monitor
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
After=multi-user.target network.target
Wants=network.target
+4 -1
View File
@@ -1,8 +1,11 @@
#!/bin/bash
set -euo pipefail
systemctl disable --now pigway-pi-control.service 2>/dev/null || true
systemctl disable --now pigway-pi-control-api.service pigway-pi-control.service 2>/dev/null || true
rm -f /etc/systemd/system/pigway-pi-control-api.service
rm -f /etc/systemd/system/pigway-pi-control.service
rm -f /usr/local/sbin/pigway-pi-control
rm -f /usr/local/sbin/pigway-pi-control-api
rm -f /etc/pigway-pi-control-api.token
rm -rf /usr/local/share/pigway-pi-control
systemctl daemon-reload
echo "程序已卸载。为防误删自定义内容,配置 /etc/pigway-pi-control.conf 已保留。"
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>PIGWay Pi Control</title>
<style>
:root{color-scheme:dark;--bg:#0b1118;--panel:#121b25;--panel2:#0f1822;--line:#263546;--text:#e7edf5;--muted:#91a4b8;--good:#50d890;--bad:#ff6b6b;--blue:#58a6ff}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top right,#14263b 0,#0b1118 42%);color:var(--text);font:14px/1.45 system-ui,-apple-system,sans-serif}main{max-width:1180px;margin:auto;padding:28px 20px 70px}header{display:flex;justify-content:space-between;align-items:end;gap:18px;margin-bottom:22px}h1{font-size:27px;margin:0}h2{font-size:18px;margin:0 0 8px}h3{font-size:15px;margin:0}.sub,.muted,.hint{color:var(--muted)}.hint{font-size:12px;margin-top:4px}.live{display:flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:var(--bad)}.dot.ok{background:var(--good);box-shadow:0 0 12px var(--good)}nav{display:flex;gap:8px;margin:18px 0;position:sticky;top:0;z-index:5;padding:8px 0;background:#0b1118e8;backdrop-filter:blur(8px)}button{border:1px solid var(--line);background:#162230;color:var(--text);border-radius:8px;padding:9px 13px;cursor:pointer}button:hover,button.active{border-color:var(--blue);background:#1b3047}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.card,.panel,.group{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 12px 28px #0004}.group{background:var(--panel2);box-shadow:none;margin-top:12px}.group-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.value{font-size:23px;font-weight:700;margin-top:4px}.wide{grid-column:span 2}.panel{margin-top:12px}.rows{display:grid;gap:8px}.row{display:flex;justify-content:space-between;gap:16px;padding:8px 0;border-bottom:1px solid #203040}.row:last-child{border:0}.badge{border-radius:999px;padding:3px 8px;background:#243447}.badge.good{color:var(--good)}.badge.bad{color:var(--bad)}.hidden{display:none!important}.log{font:12px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;padding:8px 0;border-bottom:1px solid #203040}.log-label{font:12px system-ui;color:var(--blue);margin-right:8px}.config-shell{display:grid;grid-template-columns:210px minmax(0,1fr);gap:14px;margin-top:14px}.config-menu{display:flex;flex-direction:column;gap:7px;align-self:start;position:sticky;top:66px}.config-menu button{text-align:left}.config-page{display:none}.config-page.active{display:block}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field label{display:block;color:var(--muted);font-size:12px}.field input,.field select{width:100%;margin-top:5px;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.field input:focus,.field select:focus{outline:1px solid var(--blue);border-color:var(--blue)}.span2{grid-column:span 2}.rgb-row{display:grid;grid-template-columns:48px repeat(3,1fr);gap:9px;align-items:end}.swatch{width:38px;height:38px;border-radius:50%;border:2px solid #fff3;box-shadow:0 0 12px #0008;margin-bottom:2px}.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:12px 0}.toolbar input{min-width:280px;flex:1;border:1px solid var(--line);border-radius:7px;background:#0d151e;color:var(--text);padding:9px}.savebar{position:sticky;bottom:10px;z-index:4;background:#101b27ef;border:1px solid var(--line);border-radius:10px;padding:10px;box-shadow:0 8px 30px #0008}.error{color:var(--bad)}.success{color:var(--good)}@media(max-width:800px){.grid{grid-template-columns:repeat(2,1fr)}.fields{grid-template-columns:1fr}.span2{grid-column:span 1}.wide{grid-column:span 2}.config-shell{grid-template-columns:1fr}.config-menu{position:static;display:grid;grid-template-columns:repeat(2,1fr)}.rgb-row{grid-template-columns:38px 1fr}.rgb-row .field{grid-column:2}.swatch{grid-row:1/span 3}}@media(max-width:470px){.grid{grid-template-columns:1fr}.wide{grid-column:span 1}header{align-items:start;flex-direction:column}.config-menu{grid-template-columns:1fr}}
</style>
</head>
<body><main>
<header><div><h1>PIGWay Pi Control</h1><div class="sub" id="identity">正在连接树莓派…</div></div><div class="live"><span class="dot" id="dot"></span><span id="liveText">离线</span></div></header>
<nav><button class="active" data-tab="overview">运行状态</button><button data-tab="logs">事件日志</button><button data-tab="config">系统配置</button></nav>
<section id="overview"><div class="grid">
<div class="card"><div class="muted">处理器负载</div><div class="value" id="cpu">--</div><div class="muted" id="freq">--</div></div><div class="card"><div class="muted">处理器温度</div><div class="value" id="temp">--</div><div class="muted" id="fan">--</div></div><div class="card"><div class="muted">内存占用</div><div class="value" id="memory">--</div></div><div class="card"><div class="muted">磁盘占用</div><div class="value" id="disk">--</div></div><div class="card wide"><div class="muted">主网络</div><div class="value" id="network">--</div><div class="muted" id="address">--</div></div><div class="card wide"><div class="muted">显示状态</div><div class="value" id="owner">--</div><div class="muted" id="rgb">--</div></div>
</div><div class="panel"><h2>当前告警</h2><div class="rows" id="alerts"><div class="muted">无告警</div></div></div><div class="panel"><h2>服务与进程监控</h2><div class="rows" id="watches"></div></div></section>
<section id="logs" class="hidden"><div class="panel"><div class="toolbar"><button id="refreshLogs">刷新日志</button><span class="muted">最近 200 条硬件 Agent 事件</span></div><div id="logRows"></div></div></section>
<section id="config" class="hidden"><div class="panel"><h2>系统配置</h2><p class="muted">配置按用途分组,只显示当前模式需要的选项。保存时会备份配置并重启硬件 Agent。</p><div class="toolbar"><input id="token" type="password" placeholder="配置写入令牌(Bearer Token)"><button id="toggleToken">显示令牌</button></div><div class="config-shell"><aside class="config-menu" id="configMenu"></aside><div id="configRows"></div></div><div class="savebar"><button id="saveConfig">保存并应用</button> <span id="saveState" class="muted">尚未修改</span></div></div></section>
</main><script>
const $=id=>document.getElementById(id),tabs=[...document.querySelectorAll('nav button')];let original={};const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const sectionMeta={led:['灯光设置','正常灯效与告警颜色'],alerts:['告警阈值','处理器、内存、温度、磁盘和 Wi-Fi'],fan:['风扇温控','温度档位与降档回差'],timing:['刷新周期','传感器、屏幕、网络和健康检查'],display:['屏幕布局','OLED 四行文字位置'],dark_mode:['板载指示灯','树莓派电源灯与活动灯'],services:['服务监控','监控 systemd 服务'],processes:['进程监控','监控普通进程关键字'],api:['Web 与 API','监听地址、端口和日志数量']};
const labels={'led.normal_mode':'正常状态灯效','led.normal_r':'红色通道','led.normal_g':'绿色通道','led.normal_b':'蓝色通道','led.normal_flash_on_ms':'亮灯时间','led.normal_flash_off_ms':'灭灯时间','led.normal_effect_speed':'内置效果速度','led.normal_effect_color':'内置效果颜色','led.warning_flash_on_ms':'普通告警亮灯','led.warning_flash_off_ms':'普通告警灭灯','led.critical_flash_on_ms':'严重告警亮灯','led.critical_flash_off_ms':'严重告警灭灯','led.emergency_flash_on_ms':'紧急告警亮灯','led.emergency_flash_off_ms':'紧急告警灭灯','led.write_delay_ms':'RGB 寄存器写入间隔','alerts.cpu_high':'处理器高负载阈值','alerts.cpu_recover':'处理器恢复阈值','alerts.cpu_high_seconds':'处理器持续时间','alerts.memory_high':'内存高占用阈值','alerts.memory_recover':'内存恢复阈值','alerts.memory_high_seconds':'内存持续时间','alerts.temp_high':'高温告警阈值','alerts.temp_critical':'严重高温阈值','alerts.disk_high':'磁盘高占用阈值','alerts.disk_critical':'磁盘严重阈值','alerts.wifi_weak':'Wi-Fi 弱信号阈值','alerts.wifi_recover':'Wi-Fi 恢复阈值','alerts.wifi_weak_seconds':'弱信号持续时间','fan.start':'风扇启动温度','fan.level2':'二档起始温度','fan.level3':'三档起始温度','fan.level4':'四档起始温度','fan.max':'全速起始温度','fan.hysteresis':'降档温度回差','timing.sensor_interval':'传感器采样周期','timing.oled_refresh_interval':'OLED 刷新周期','timing.page_interval':'OLED 页面轮播周期','timing.network_interval':'网络检查周期','timing.service_interval':'服务检查周期','timing.health_interval':'健康检查周期','display.row1_y':'第一行纵坐标','display.row2_y':'第二行纵坐标','display.row3_y':'第三行纵坐标','display.row4_y':'第四行纵坐标','dark_mode.pi_pwr_led':'树莓派电源灯','dark_mode.pi_act_led':'树莓派活动灯','api.bind':'监听地址','api.port':'监听端口','api.log_limit':'默认日志条数'};
['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>labels[`led.${g}_${c}`]=`${({r:'红',g:'绿',b:'蓝'})[c]}色通道`));
const hints={'led.normal_mode':'仅在没有当前告警时生效;告警会立即抢占。','led.normal_r':'范围 0–255。','led.normal_g':'范围 0–255。','led.normal_b':'范围 0–255。','led.normal_flash_on_ms':'范围 50–60000 毫秒。','led.normal_flash_off_ms':'范围 50–60000 毫秒。','led.normal_effect_speed':'MCU 内置速度档位。','led.normal_effect_color':'仅流水灯和呼吸灯使用。','led.write_delay_ms':'硬件校准项,通常保持 10 毫秒。','alerts.wifi_weak':'单位 dBm,数值越接近 0 信号越强。','alerts.wifi_recover':'单位 dBm,应高于弱信号阈值。','fan.hysteresis':'避免临界温度附近频繁切换档位。','api.bind':'0.0.0.0 允许局域网访问;127.0.0.1 仅本机。','api.port':'范围 1–65535;保存后 API 自动重启。','dark_mode.pi_pwr_led':'选择“关闭”时由 PIGWay 熄灭板载灯。','dark_mode.pi_act_led':'选择“关闭”时由 PIGWay 熄灭板载灯。'};
const options={'led.normal_mode':[['off','全灭'],['solid','自定义颜色常亮'],['flash','自定义颜色闪烁'],['flow','流水灯'],['breathe','呼吸灯'],['marquee','跑马灯'],['rainbow','彩虹灯'],['colorful','炫彩灯']],'led.normal_effect_speed':[['1','慢速'],['2','中速'],['3','快速']],'led.normal_effect_color':[['red','红色'],['green','绿色'],['blue','蓝色'],['yellow','黄色'],['purple','紫色'],['cyan','青色'],['white','白色']],'dark_mode.pi_pwr_led':[['off','关闭'],['on','不干预']],'dark_mode.pi_act_led':[['off','关闭'],['on','不干预']]};
const numberRules={'led.normal_r':[0,255,1],'led.normal_g':[0,255,1],'led.normal_b':[0,255,1],'led.normal_flash_on_ms':[50,60000,10],'led.normal_flash_off_ms':[50,60000,10],'led.warning_flash_on_ms':[50,60000,10],'led.warning_flash_off_ms':[50,60000,10],'led.critical_flash_on_ms':[50,60000,10],'led.critical_flash_off_ms':[50,60000,10],'led.emergency_flash_on_ms':[50,60000,10],'led.emergency_flash_off_ms':[50,60000,10],'led.write_delay_ms':[1,1000,1],'alerts.cpu_high':[0,100,1],'alerts.cpu_recover':[0,100,1],'alerts.cpu_high_seconds':[1,86400,1],'alerts.memory_high':[0,100,1],'alerts.memory_recover':[0,100,1],'alerts.memory_high_seconds':[1,86400,1],'alerts.temp_high':[0,120,.1],'alerts.temp_critical':[0,120,.1],'alerts.disk_high':[0,100,1],'alerts.disk_critical':[0,100,1],'alerts.wifi_weak':[-120,0,1],'alerts.wifi_recover':[-120,0,1],'alerts.wifi_weak_seconds':[1,86400,1],'fan.start':[0,100,.1],'fan.level2':[0,100,.1],'fan.level3':[0,100,.1],'fan.level4':[0,100,.1],'fan.max':[0,100,.1],'fan.hysteresis':[0,20,.1],'timing.sensor_interval':[.2,3600,.1],'timing.oled_refresh_interval':[.2,3600,.1],'timing.page_interval':[1,3600,.1],'timing.network_interval':[1,3600,.1],'timing.service_interval':[1,3600,.1],'timing.health_interval':[.2,3600,.1],'display.row1_y':[0,31,1],'display.row2_y':[0,31,1],'display.row3_y':[0,31,1],'display.row4_y':[0,31,1],'api.port':[1,65535,1],'api.log_limit':[1,1000,1]};['cpu','power','memory','storage','network','service'].forEach(g=>['r','g','b'].forEach(c=>numberRules[`led.${g}_${c}`]=[0,255,1]));
const sectionOrder=['led','alerts','fan','timing','display','dark_mode','services','processes','api'],alertColorGroups=[['cpu','处理器、温度与散热'],['power','供电与欠压'],['memory','内存'],['storage','存储'],['network','网络与 Wi-Fi'],['service','服务与进程']];
const ownerNames={NORMAL_HOME:'正常主页',ROOT_READONLY:'根文件系统只读',TEMP_CRITICAL:'处理器温度严重',POWER_UNDERVOLT:'供电欠压',THROTTLED:'处理器降频',FREQ_CAPPED:'频率受限',NETWORK_DOWN:'网络断开',INTERNET_DOWN:'互联网断开',SERVICE_DOWN:'服务停止',PROCESS_DOWN:'进程停止',DISK_CRITICAL:'磁盘严重不足',TEMP_HIGH:'处理器温度偏高',CPU_HIGH:'处理器负载过高',MEMORY_HIGH:'内存占用过高',DISK_HIGH:'磁盘空间不足',WIFI_WEAK:'Wi-Fi 信号弱'},rgbNames={OFF:'全灭',NORMAL_SOLID:'正常 · 自定义常亮',NORMAL_FLASH:'正常 · 自定义闪烁',NORMAL_FLOW:'正常 · 流水灯',NORMAL_BREATHE:'正常 · 呼吸灯',NORMAL_MARQUEE:'正常 · 跑马灯',NORMAL_RAINBOW:'正常 · 彩虹灯',NORMAL_COLORFUL:'正常 · 炫彩灯'},eventNames={START:'程序启动',STOP:'程序停止',ALERT_ACTIVE:'告警出现',ALERT_RECOVERED:'告警恢复',DISPLAY_OWNER:'显示对象切换',RGB_MODE:'RGB 模式切换',INTERNET_DOWN:'互联网断开',INTERNET_RECOVERED:'互联网恢复',NETWORK_DOWN:'网络断开',NETWORK_RECOVERED:'网络恢复',POWER_LOW:'供电欠压',POWER_RECOVERED:'供电恢复'};
function ownerName(id){const [type,suffix]=String(id||'').split('|',2);return(ownerNames[type]||type)+(suffix?` · ${suffix}`:'')}function rgbName(mode){if(rgbNames[mode])return rgbNames[mode];const p=String(mode||'').split('_'),groups={CPU:'处理器',POWER:'供电',MEMORY:'内存',STORAGE:'存储',NETWORK:'网络',SERVICE:'服务'},levels={WARNING:'普通告警',CRITICAL:'严重告警',EMERGENCY:'紧急告警'};return`${groups[p[0]]||p[0]} · ${levels[p[1]]||p[1]}闪烁`}
function field(section,key,value,extra=''){const id=`${section}.${key}`,opts=options[id],rule=numberRules[id],label=labels[id]||key,help=hints[id]||'';const control=opts?`<select data-section="${esc(section)}" data-key="${esc(key)}">${opts.map(([v,l])=>`<option value="${v}" ${String(value)===v?'selected':''}>${l}</option>`).join('')}</select>`:`<input ${rule?'type="number"':''} ${rule?`min="${rule[0]}" max="${rule[1]}" step="${rule[2]}"`:''} data-section="${esc(section)}" data-key="${esc(key)}" value="${esc(value)}">`;return`<div class="field ${extra}"><label>${esc(label)}${control}</label>${help?`<div class="hint">${esc(help)}</div>`:''}</div>`}
function rgbGroup(values,prefix,title){const r=values[`${prefix}_r`],g=values[`${prefix}_g`],b=values[`${prefix}_b`];return`<div class="group"><div class="group-head"><h3>${title}</h3><span class="muted">RGB 自定义颜色</span></div><div class="rgb-row"><span class="swatch" data-swatch="${prefix}" style="background:rgb(${r},${g},${b})"></span>${['r','g','b'].map(c=>field('led',`${prefix}_${c}`,values[`${prefix}_${c}`])).join('')}</div></div>`}
function normalGroup(v){return`<div class="group"><div class="group-head"><div><h3>正常运行灯效</h3><div class="hint">没有告警时显示;告警出现后自动让位。</div></div></div><div class="fields">${field('led','normal_mode',v.normal_mode,'span2')}<div class="normal-field span2" data-modes="solid flash">${rgbGroup(v,'normal','自定义颜色')}</div><div class="normal-field" data-modes="flash">${field('led','normal_flash_on_ms',v.normal_flash_on_ms)}</div><div class="normal-field" data-modes="flash">${field('led','normal_flash_off_ms',v.normal_flash_off_ms)}</div><div class="normal-field" data-modes="flow breathe marquee rainbow colorful">${field('led','normal_effect_speed',v.normal_effect_speed)}</div><div class="normal-field" data-modes="flow breathe">${field('led','normal_effect_color',v.normal_effect_color)}</div></div></div>`}
function renderLed(v){const flash=['warning','critical','emergency'].map((x,i)=>`<div class="group"><h3>${['普通告警','严重告警','紧急告警'][i]}闪烁节奏</h3><div class="fields">${field('led',`${x}_flash_on_ms`,v[`${x}_flash_on_ms`])}${field('led',`${x}_flash_off_ms`,v[`${x}_flash_off_ms`])}</div></div>`).join(''),advanced=v.write_delay_ms===undefined?'':`<div class="group"><h3>硬件高级参数</h3><div class="fields">${field('led','write_delay_ms',v.write_delay_ms)}</div></div>`;return normalGroup(v)+`<div class="group"><div class="group-head"><div><h3>告警对象颜色</h3><div class="hint">告警对象决定颜色,严重程度决定闪烁速度。</div></div></div>${alertColorGroups.map(([p,t])=>rgbGroup(v,p,t)).join('')}</div>`+flash+advanced}
function renderGeneral(s,v){const [t,d]=sectionMeta[s]||[s,''];return`<div class="group"><div class="group-head"><div><h3>${t}</h3><div class="hint">${d}</div></div></div><div class="fields">${Object.entries(v).map(([k,x])=>field(s,k,x)).join('')||'<div class="muted">暂无配置项</div>'}</div></div>`}
function updateConditional(){const mode=document.querySelector('[data-section="led"][data-key="normal_mode"]')?.value||'off';document.querySelectorAll('.normal-field').forEach(x=>x.classList.toggle('hidden',!x.dataset.modes.split(' ').includes(mode)));updateSwatches()}function updateSwatches(){document.querySelectorAll('[data-swatch]').forEach(s=>{const p=s.dataset.swatch,read=c=>Number(document.querySelector(`[data-section="led"][data-key="${p}_${c}"]`)?.value||0);s.style.background=`rgb(${read('r')},${read('g')},${read('b')})`})}
function renderConfig(){const ss=sectionOrder.filter(s=>original[s]);$('configMenu').innerHTML=ss.map((s,i)=>`<button class="${i===0?'active':''}" data-config-section="${s}">${sectionMeta[s]?.[0]||s}</button>`).join('');$('configRows').innerHTML=ss.map((s,i)=>`<section class="config-page ${i===0?'active':''}" data-config-page="${s}"><h2>${sectionMeta[s]?.[0]||s}</h2><p class="muted">${sectionMeta[s]?.[1]||''}</p>${s==='led'?renderLed(original[s]):renderGeneral(s,original[s])}</section>`).join('');$('configMenu').onclick=e=>{const b=e.target.closest('button');if(!b)return;document.querySelectorAll('[data-config-section]').forEach(x=>x.classList.toggle('active',x===b));document.querySelectorAll('[data-config-page]').forEach(x=>x.classList.toggle('active',x.dataset.configPage===b.dataset.configSection))};$('configRows').oninput=e=>{if(e.target.matches('[data-section="led"]'))updateConditional();markChanged()};updateConditional()}
function markChanged(){let n=0;document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key])n++});$('saveState').textContent=n?`有 ${n} 项尚未保存`:'尚未修改';$('saveState').className=n?'':'muted'}
tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));['overview','logs','config'].forEach(id=>$(id).classList.toggle('hidden',id!==b.dataset.tab));if(b.dataset.tab==='logs')loadLogs();if(b.dataset.tab==='config')loadConfig()});async function get(url,opt){const r=await fetch(url,opt),text=await r.text();let j;try{j=JSON.parse(text)}catch{throw Error(`服务器返回异常 (${r.status})`)}if(!r.ok)throw Error(j.error||`请求失败 (${r.status})`);return j}function rows(target,items,empty='暂无数据'){$(target).innerHTML=items.length?items.join(''):`<div class="muted">${empty}</div>`}
async function loadStatus(){try{const s=await get('/api/v1/status'),y=s.system,n=s.network,d=s.display;$('dot').classList.add('ok');$('liveText').textContent='在线';$('identity').textContent=`版本 ${s.version} · 已运行 ${Math.floor(s.uptime_seconds/3600)} 小时 · ${new Date(s.timestamp*1000).toLocaleTimeString()}`;$('cpu').textContent=`${y.cpu_percent}%`;$('freq').textContent=`频率 ${y.cpu_freq_mhz} MHz`;$('temp').textContent=`${y.temperature_c}°C`;$('fan').textContent=`风扇 ${({OFF:'关闭',L1:'一档',L2:'二档',L3:'三档',L4:'四档',MAX:'全速'})[y.fan_name]||y.fan_name}`;$('memory').textContent=`${y.memory_percent}%`;$('disk').textContent=`${y.disk_percent}%`;$('network').textContent=`${n.kind==='WIF'?'无线网络':n.kind==='ETH'?'有线网络':n.kind} ${n.metric}`;$('address').textContent=`${n.interface} · ${n.ip_label==='IP4'?'IPv4':'IPv6'} ${n.ip} · 互联网${n.internet?'正常':'断开'}`;$('owner').textContent=ownerName(d.owner);$('rgb').textContent=`OLED:${ownerName(d.oled_page)} · RGB:${rgbName(d.rgb_mode)}`;rows('alerts',d.alerts.map(a=>`<div class="row"><span><b>${esc(ownerName(a.id))}</b><br><span class="muted">${esc(a.l2)} ${esc(a.l3)}</span></span><span class="badge bad">等级 ${a.severity} · 优先级 ${a.priority}</span></div>`),'当前没有告警');rows('watches',s.watches.map(w=>`<div class="row"><span><b>${esc(w.name)}</b><br><span class="muted">${w.type==='service'?'系统服务':'普通进程'} · ${esc(w.target)}</span></span><span class="badge ${w.up?'good':'bad'}">${w.up?'正常':'停止'}</span></div>`))}catch(e){$('dot').classList.remove('ok');$('liveText').textContent='离线';$('identity').textContent=e.message}}
async function loadLogs(){try{const d=await get('/api/v1/logs?limit=200');rows('logRows',d.logs.reverse().map(x=>{const m=String(x.message).match(/event=([^ ]+)/),label=m?(eventNames[m[1]]||m[1]):'系统事件';return`<div class="log"><span class="log-label">${esc(label)}</span>${esc(x.message)}</div>`}))}catch(e){$('logRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}async function loadConfig(){try{const d=await get('/api/v1/config');original=d.config;renderConfig();markChanged()}catch(e){$('configRows').innerHTML=`<div class="error">${esc(e.message)}</div>`}}
$('refreshLogs').onclick=loadLogs;$('token').value=localStorage.getItem('pigwayToken')||'';$('toggleToken').onclick=()=>{const i=$('token'),show=i.type==='password';i.type=show?'text':'password';$('toggleToken').textContent=show?'隐藏令牌':'显示令牌'};$('saveConfig').onclick=async()=>{const token=$('token').value.trim();localStorage.setItem('pigwayToken',token);const updates={};document.querySelectorAll('#configRows [data-section]').forEach(i=>{if(i.value!==original[i.dataset.section][i.dataset.key]){(updates[i.dataset.section]??={})[i.dataset.key]=i.value}});if(!Object.keys(updates).length){$('saveState').textContent='没有需要保存的修改';return}try{$('saveConfig').disabled=true;$('saveState').textContent='正在保存并应用…';const d=await get('/api/v1/config',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},body:JSON.stringify({updates})});$('saveState').textContent=`保存成功,已创建备份 ${d.backup}`;$('saveState').className='success';setTimeout(loadConfig,1500)}catch(e){$('saveState').textContent=e.message;$('saveState').className='error'}finally{$('saveConfig').disabled=false}};loadStatus();setInterval(loadStatus,3000);
</script></body></html>