Compare commits

...
Author SHA1 Message Date
way 974121de99 merge: bilingual interface and appearance modes 2026-09-27 13:59:12 +08:00
way 5d744824a8 feat: add bilingual interface and appearance modes 2026-09-27 13:59:11 +08:00
way b08721fd15 Merge fix/monitor-pause-rgb-edges 2026-09-27 13:49:10 +08:00
way cd9bdd5970 fix: separate monitor pause and stabilize RGB edges 2026-09-27 13:49:09 +08:00
way 08c00aa370 Merge feature/log-query 2026-09-27 13:42:59 +08:00
way 13f6155096 feat: add searchable event log history 2026-09-27 13:42:58 +08:00
way 97598a48ad Merge fix/service-controls-layout 2026-09-27 13:29:12 +08:00
way 01c07658c0 fix: refine service monitor controls 2026-09-27 13:29:11 +08:00
way 4ba4d461db Merge fix/web-rgb-alignment 2026-09-27 13:24:07 +08:00
way 45c7b8c168 fix: align RGB color controls 2026-09-27 13:24:06 +08:00
way b39c7e5279 Merge feature/web-controls 2026-09-27 11:20:46 +08:00
way 2e5562fb3e feat: improve web configuration controls 2026-09-27 11:20:45 +08:00
way 03fd6d1cd8 Merge feature/web-config-ui 2026-09-27 11:00:42 +08:00
way 8cb71cbe9f feat: reorganize and localize web configuration 2026-09-27 11:00:41 +08:00
way 73907320c8 Merge fix/rgb-effect-commit-order 2026-09-27 10:46:20 +08:00
way ad25c08eb1 fix: commit MCU RGB effects in calibrated order 2026-09-27 10:46:18 +08:00
way 5d541d5ef6 Merge feature/normal-rgb 2026-09-27 10:36:33 +08:00
way a8ca071b56 feat: add configurable normal RGB effects 2026-09-27 10:36:32 +08:00
way 419d27b570 Merge fix/pigway-brand-force-install 2026-09-27 10:29:02 +08:00
way 73a60b4866 fix: standardize PIGWay branding and conflict override 2026-09-27 10:29:01 +08:00
way 576006b2fd Merge fix/i2c-owner-preflight 2026-09-27 10:25:57 +08:00
9 changed files with 382 additions and 72 deletions
+15 -3
View File
@@ -1,4 +1,4 @@
# Pigway Pi Control v3.6.0 # PIGWay Pi Control v3.6.0
Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。 Raspberry Pi 4B + 配套 128×32 OLED / 风扇 / RGB 散热扩展板的本机硬件监控 Agent。
@@ -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 记录。 每个当前故障都是 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. 结构化事件日志 ### 6. 结构化事件日志
@@ -160,8 +162,13 @@ GET /api/v1/health
GET /api/v1/status GET /api/v1/status
GET /api/v1/logs?limit=200 GET /api/v1/logs?limit=200
GET /api/v1/config GET /api/v1/config
GET /api/v1/services
``` ```
日志接口支持 `since`、`until` Unix 时间戳,`severity`、`event`、`search` 筛选,`timestamp` / `severity` / `event` / `message` 排序,以及 `limit` / `offset` 分页。Web 提供今天、昨天、本周、本月和全部快捷范围。日志直接读取 systemd journal 的现有保留数据,不建立重复的 SQLite 日志副本。
Web 界面支持中文和 English 切换,并将结构化 journal 事件类型显示为当前界面的语言;journal 内部稳定的事件标识保持不变,便于脚本筛选。外观可选择自动、深色或浅色,自动模式跟随浏览器/操作系统配色。语言和外观偏好只保存在当前浏览器中。
配置写入接口: 配置写入接口:
```text ```text
@@ -172,6 +179,10 @@ Content-Type: application/json
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}} {"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
``` ```
服务监控页面会列出本机 systemd 服务,并标识“已监控/未监控”。添加、编辑或删除监控使用 `PUT /api/v1/services`;启动或停止服务使用 `PUT /api/v1/services/control`,两者均要求 Bearer Token。API 服务不能通过自己的请求停止自身,其他服务由用户自行管理。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。
“暂停监控/恢复监控”只改变 PIGWay 是否检查该服务,不会启动或停止 systemd unit;暂停状态保存在 `[service_monitor_disabled]`。只有明确标注为“启动服务/停止服务”的按钮才会调用 systemctl。
读取 token: 读取 token:
```bash ```bash
@@ -208,6 +219,7 @@ sudo ./install.sh
- 安装必要依赖 - 安装必要依赖
- 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用 - 检查 I²C,并在安装前检测 `/dev/i2c-1` 是否被其他 OLED/LED 控制进程占用
- 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务 - 发现冲突时列出进程并退出,由用户决定如何处理;安装器不会停止、禁用或杀死其他服务
- 确认需要多个进程共用 I²C 时,可用 `sudo ./install.sh --force-i2c-conflict` 强制继续;冲突进程仍由用户自行管理
- 安装字体、程序、配置和 systemd unit - 安装字体、程序、配置和 systemd unit
- 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先 - 完整保留用户现有配置(包括所有 section、用户值和注释),仅补入缺失的 `[led]` 和 `[api]` 选项,已有用户值优先
- 生成只读 root token,安装 API/Web 文件 - 生成只读 root token,安装 API/Web 文件
+8 -8
View File
@@ -1,10 +1,10 @@
0960943a15ae23cd067f1169df6640723500107e8c41af070b0c9b889f29914e README.md d11f57b075bf1310515456d8c8b9bf1c5bb33753d8c9b10d08984f7014a8efd8 README.md
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
29f59e41052e1e251bf2f38274cdd321fc195602220b61ac8890202128d7c50a app/pigway_pi_control.py ba34a479584345590f1903851b6d7e13000c4034c0c9db39e591eb4382be4229 app/pigway_pi_control.py
f32be5b63fe2be51e39c106c232ae3cc14dab7c0c3719e2609b9e93a7802b6c3 app/pigway_pi_control_api.py a833c5598813b3695ac5a65daacf7b503abd8036f20f2e6cb75d8a99307a1095 app/pigway_pi_control_api.py
9f3b2be557b3cd93c309f8a1cadce7d34b23ca06c1ff69231d934e8180fefb48 config/pigway-pi-control.conf 72dc248b8f7020b81bc4069e52cc11175a7b42e00d3b484cb6ca5beca624a90b config/pigway-pi-control.conf
15b431e62b00886b6561597db059f01f116b5173bdae7a5817f617f7bc73a3ec install.sh f34eac1df180a3da3a6998d650c06435fd89728c10b090f6162df82e53986dd0 install.sh
81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service 4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
5d7562c496de597f61b62af48f8f6350ad270e1ebf725426d9f1e5b5d698055e systemd/pigway-pi-control-api.service aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh 9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
4b4066d104202f664f5df07c97d4e256dde32b5f4426e6a955a3ecef5e08df60 web/index.html f5ea0e6784143c35728b9d3b9aba0490527d14e469e040a4db5a9d37d3ab2c55 web/index.html
+58 -6
View File
@@ -210,7 +210,9 @@ def load_cfg():
if c.has_section("services"): if c.has_section("services"):
for label,target in c["services"].items(): for label,target in c["services"].items():
target=target.strip() target=target.strip()
if target and not target.startswith("#"): watches.append(("service",label.upper(),target)) paused=c.getboolean("service_monitor_disabled",label,fallback=False)
if target and not target.startswith("#") and not paused:
watches.append(("service",label.upper(),target))
if c.has_section("processes"): if c.has_section("processes"):
for label,target in c["processes"].items(): for label,target in c["processes"].items():
target=target.strip() target=target.strip()
@@ -253,6 +255,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))) 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))) 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))) 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): def rgb_cfg(name,default):
return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int))) return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int)))
@@ -441,6 +457,29 @@ def rgb_flash(mode,on):
mcu(0x03,channels[2]) mcu(0x03,channels[2])
_last_rgb=state _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): def render_home(cpu,temp,mem,disk):
# 128x32 fixed columns. Both columns are LEFT-aligned. # 128x32 fixed columns. Both columns are LEFT-aligned.
# Seller 5x7 font has 6px character pitch; right column starts at x=72. # Seller 5x7 font has 6px character pitch; right column starts at x=72.
@@ -485,7 +524,7 @@ class DisplayController:
now=time.monotonic() now=time.monotonic()
self.lock=threading.RLock() self.lock=threading.RLock()
self.requests={"NORMAL_HOME":{ 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, "title":"","l2":"","l3":"","l4":"","active":True,
"first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)}, "first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)},
}} }}
@@ -629,7 +668,8 @@ class DisplayController:
} }
def _rgb_worker(self): def _rgb_worker(self):
previous_error=None; applied_mode="OFF"; applied_flash=False; animation_started=time.monotonic() previous_error=None; applied_mode="OFF"; applied_flash=False
flash_on=None; animation_started=time.monotonic()
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
with self.lock: wanted_mode=self.current_rgb_mode with self.lock: wanted_mode=self.current_rgb_mode
@@ -637,18 +677,30 @@ class DisplayController:
if wanted_mode!=applied_mode: if wanted_mode!=applied_mode:
with bus_lock: with bus_lock:
if wanted_mode=="OFF": rgb_off() 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}") else: raise ValueError(f"unsupported RGB mode: {wanted_mode}")
log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode) log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode)
applied_mode=wanted_mode; animation_started=time.monotonic() applied_mode=wanted_mode; animation_started=time.monotonic()
flash_on=True if wanted_mode.endswith("_FLASH") else None
applied_flash=applied_mode.endswith("_FLASH") applied_flash=applied_mode.endswith("_FLASH")
if applied_flash: if applied_flash:
elapsed_ms=(time.monotonic()-animation_started)*1000.0 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 elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH
else: on_ms,off_ms=WARNING_FLASH else: on_ms,off_ms=WARNING_FLASH
on=elapsed_ms%(on_ms+off_ms)<on_ms on=elapsed_ms%(on_ms+off_ms)<on_ms
with bus_lock: rgb_flash(applied_mode,on) if on!=flash_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)
flash_on=on
if previous_error is not None: log("INFO","RGB_RECOVERED") if previous_error is not None: log("INFO","RGB_RECOVERED")
previous_error=None previous_error=None
except Exception as e: except Exception as e:
+149 -13
View File
@@ -12,8 +12,12 @@ TOKEN=Path("/etc/pigway-pi-control-api.token")
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html") WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
ALLOWED_SECTIONS={"services","processes","alerts","fan","led","timing","dark_mode","display","api"} ALLOWED_SECTIONS={"services","processes","alerts","fan","led","timing","dark_mode","display","api"}
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$") NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service)_[rgb]$") UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency)_flash_(on|off)_ms$") 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"}
PROTECTED_UNITS={"pigway-pi-control-api.service"}
def load_cfg(): def load_cfg():
cfg=configparser.ConfigParser(); cfg.read(CFG) cfg=configparser.ConfigParser(); cfg.read(CFG)
@@ -36,6 +40,12 @@ def validate_value(section,key,value,existing):
elif FLASH_KEY_RE.fullmatch(key): elif FLASH_KEY_RE.fullmatch(key):
number=int(value) number=int(value)
if not 50<=number<=60000: raise ValueError(f"{key} must be 50..60000") 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": elif section=="api" and key=="port":
number=int(value) number=int(value)
if not 1<=number<=65535: raise ValueError("api.port must be 1..65535") if not 1<=number<=65535: raise ValueError("api.port must be 1..65535")
@@ -81,21 +91,121 @@ def update_ini(updates):
temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG) temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup) return str(backup)
def journal(limit): def journal_query(since=None,until=None,severity="",event="",search="",sort="timestamp",order="desc",limit=100,offset=0):
result=subprocess.run(["journalctl","-u","pigway-pi-control.service","-n",str(limit), command=["journalctl","-u","pigway-pi-control.service","--no-pager","-o","json"]
"--no-pager","-o","json"],capture_output=True,text=True,timeout=5) if since is not None: command.extend(["--since",f"@{since}"])
if until is not None: command.extend(["--until",f"@{until}"])
result=subprocess.run(command,capture_output=True,text=True,timeout=15)
if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed") if result.returncode: raise RuntimeError(result.stderr.strip() or "journalctl failed")
rows=[] rows=[]
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
try: try:
item=json.loads(line) item=json.loads(line)
rows.append({"timestamp":item.get("__REALTIME_TIMESTAMP"),"priority":item.get("PRIORITY"), message=item.get("MESSAGE","")
"message":item.get("MESSAGE","")}) level_match=re.search(r"(?:^|\s)level=([A-Z]+)(?:\s|$)",message)
event_match=re.search(r"(?:^|\s)event=([^ ]+)",message)
priority=str(item.get("PRIORITY","6"))
level=(level_match.group(1) if level_match else
{"0":"EMERGENCY","1":"ALERT","2":"CRITICAL","3":"ERROR","4":"WARN","5":"NOTICE","6":"INFO","7":"DEBUG"}.get(priority,"INFO"))
rows.append({"timestamp":int(item.get("__REALTIME_TIMESTAMP",0)),"priority":priority,
"severity":level,"event":event_match.group(1) if event_match else "SYSTEM",
"message":message})
except json.JSONDecodeError: pass except json.JSONDecodeError: pass
return rows severities={x.strip().upper() for x in severity.split(",") if x.strip()}
if severities: rows=[x for x in rows if x["severity"] in severities]
event_types=sorted({x["event"] for x in rows})
if event: rows=[x for x in rows if x["event"]==event]
if search:
needle=search.casefold(); rows=[x for x in rows if needle in x["message"].casefold()]
severity_rank={"EMERGENCY":0,"ALERT":1,"CRITICAL":2,"ERROR":3,"WARN":4,"NOTICE":5,"INFO":6,"DEBUG":7}
keys={"timestamp":lambda x:x["timestamp"],"severity":lambda x:severity_rank.get(x["severity"],99),
"event":lambda x:x["event"],"message":lambda x:x["message"]}
rows.sort(key=keys[sort],reverse=order=="desc")
total=len(rows)
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
"event_types":event_types,"retention":"systemd-journal"}
def service_inventory():
cfg=load_cfg()
monitored={target.lower():(name,target) for name,target in cfg.items("services")} if cfg.has_section("services") else {}
notes=dict(cfg.items("service_notes")) if cfg.has_section("service_notes") else {}
files=subprocess.run(["systemctl","list-unit-files","--type=service","--no-legend","--no-pager"],
capture_output=True,text=True,timeout=10,check=True)
units={}
for line in files.stdout.splitlines():
parts=line.split()
if len(parts)>=2 and UNIT_RE.fullmatch(parts[0]):
units[parts[0]]={"unit":parts[0],"enabled":parts[1],"active":"inactive","description":""}
states=subprocess.run(["systemctl","list-units","--all","--type=service","--no-legend","--no-pager","--plain"],
capture_output=True,text=True,timeout=10,check=True)
for line in states.stdout.splitlines():
parts=line.split(None,4)
if len(parts)>=4 and UNIT_RE.fullmatch(parts[0]):
item=units.setdefault(parts[0],{"unit":parts[0],"enabled":"unknown"})
item.update({"active":parts[2],"description":parts[4] if len(parts)>4 else ""})
for name,target in monitored.values():
item=units.setdefault(target,{"unit":target,"enabled":"not-found","active":"inactive","description":""})
paused=cfg.getboolean("service_monitor_disabled",name,fallback=False)
item.update({"monitored":True,"paused":paused,"monitor_name":name.upper(),"note":notes.get(name,"")})
for item in units.values():
item.setdefault("monitored",False); item.setdefault("paused",False)
item.setdefault("monitor_name",""); item.setdefault("note","")
item["protected"]=item["unit"] in PROTECTED_UNITS
return sorted(units.values(),key=lambda x:(not x["monitored"],x["unit"]))
def edit_service_monitor(operation,name,target,note="",previous_name=""):
if operation not in {"save","delete","pause","resume"}: raise ValueError("invalid monitor operation")
if not NAME_RE.fullmatch(name): raise ValueError("invalid monitor name")
if not UNIT_RE.fullmatch(target): raise ValueError("invalid systemd service name")
if len(note)>120 or any(c in note for c in "\r\n\x00"): raise ValueError("invalid note")
if previous_name and not NAME_RE.fullmatch(previous_name): raise ValueError("invalid previous monitor name")
cfg=load_cfg(); old_target=cfg.get("services",name,fallback=None)
previous=previous_name or name
was_paused=cfg.getboolean("service_monitor_disabled",previous,fallback=False)
text=CFG.read_text()
def change(section,key,value):
nonlocal text
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
if not section_match:
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
return
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(key)}\s*=.*(?:\n|$)",chunk)
if key_match:
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
elif value is not None:
text=text[:end].rstrip()+f"\n{key} = {value}\n\n"+text[end:].lstrip("\n")
if operation=="delete":
if old_target is None: raise ValueError("monitor not found")
change("services",name,None); change("service_notes",name,None); change("service_monitor_disabled",name,None)
elif operation in {"pause","resume"}:
if old_target is None: raise ValueError("monitor not found")
change("service_monitor_disabled",name,"true" if operation=="pause" else None)
else:
if previous_name and previous_name.lower()!=name.lower():
change("services",previous_name,None); change("service_notes",previous_name,None)
change("service_monitor_disabled",previous_name,None)
change("services",name,target); change("service_notes",name,note or None)
if was_paused: change("service_monitor_disabled",name,"true")
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)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
return str(backup)
def control_service(unit,action):
if not UNIT_RE.fullmatch(unit): raise ValueError("invalid systemd service name")
if action not in {"start","stop"}: raise ValueError("action must be start or stop")
if unit in PROTECTED_UNITS and action=="stop": raise ValueError("protected remote-management service cannot be stopped here")
result=subprocess.run(["systemctl",action,unit],capture_output=True,text=True,timeout=20)
if result.returncode: raise RuntimeError(result.stderr.strip() or f"systemctl {action} failed")
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version="PigwayAPI/3.6" server_version="PIGWayAPI/3.6"
def log_message(self,fmt,*args): def log_message(self,fmt,*args):
return return
@@ -134,18 +244,44 @@ class Handler(BaseHTTPRequestHandler):
if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return if not STATUS.exists(): self.send_json(503,{"error":"agent status unavailable"}); return
self.send_json(200,json.loads(STATUS.read_text())); return self.send_json(200,json.loads(STATUS.read_text())); return
if parsed.path=="/api/v1/logs": 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) query=parse_qs(parsed.query)
self.send_json(200,{"logs":journal(limit)}); return value=lambda key,default="":query.get(key,[default])[0]
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),500)
offset=max(int(value("offset",0)),0)
since=int(value("since")) if value("since") else None
until=int(value("until")) if value("until") else None
if since is not None and until is not None and since>until: raise ValueError("since must not be after until")
sort=value("sort","timestamp"); order=value("order","desc")
if sort not in {"timestamp","severity","event","message"}: raise ValueError("invalid log sort")
if order not in {"asc","desc"}: raise ValueError("invalid log order")
severity=value("severity").upper(); event=value("event"); search=value("search")
if len(event)>80 or len(search)>120: raise ValueError("log filter is too long")
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
if parsed.path=="/api/v1/config": if parsed.path=="/api/v1/config":
self.send_json(200,{"config":config_json(),"write_requires_token":True}); return self.send_json(200,{"config":config_json(),"write_requires_token":True}); return
if parsed.path=="/api/v1/services":
self.send_json(200,{"services":service_inventory()}); return
self.send_json(404,{"error":"not found"}) self.send_json(404,{"error":"not found"})
except ValueError as e: self.send_json(400,{"error":str(e)})
except Exception as e: self.send_json(500,{"error":str(e)}) except Exception as e: self.send_json(500,{"error":str(e)})
def do_PUT(self): def do_PUT(self):
if urlparse(self.path).path!="/api/v1/config": self.send_json(404,{"error":"not found"}); return path=urlparse(self.path).path
if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control"}: self.send_json(404,{"error":"not found"}); return
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
try: try:
body=self.read_json(); updates=body.get("updates") body=self.read_json()
if path=="/api/v1/services/control":
control_service(str(body.get("unit","")),str(body.get("action","")))
print(f"SERVICE_CONTROL action={body.get('action')} unit={body.get('unit')}",flush=True)
self.send_json(200,{"ok":True}); return
if path=="/api/v1/services":
backup=edit_service_monitor(str(body.get("operation","")),str(body.get("name","")),
str(body.get("target","")),str(body.get("note","")),
str(body.get("previous_name","")))
print(f"SERVICE_MONITOR_UPDATED operation={body.get('operation')} target={body.get('target')} backup={backup}",flush=True)
self.send_json(200,{"ok":True,"backup":backup}); return
updates=body.get("updates")
backup=update_ini(updates) backup=update_ini(updates)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10) subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
restart_api="api" in updates restart_api="api" in updates
+36 -4
View File
@@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# Pigway Pi Control v3.6.0 - 用户配置文件 # PIGWay Pi Control v3.6.0 - 用户配置文件
# ============================================================================= # =============================================================================
# #
# 正式安装后的配置路径: # 正式安装后的配置路径:
@@ -147,6 +147,34 @@ wifi_weak_seconds = 30
[led] [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 同步显示 # DisplayController 按集中定义的 priority 仲裁 owner。告警页显示时 RGB 同步显示
# 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级: # 该页对象;主页使用最高优先级 active owner。颜色表示对象,速度表示严重等级:
# CPU/频率/温度/散热 = 红色 # CPU/频率/温度/散热 = 红色
@@ -183,8 +211,6 @@ critical_flash_on_ms = 750
critical_flash_off_ms = 750 critical_flash_off_ms = 750
emergency_flash_on_ms = 300 emergency_flash_on_ms = 300
emergency_flash_off_ms = 300 emergency_flash_off_ms = 300
# NORMAL_HOME 为 owner 时 RGB OFF。
#
# 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序, # 每次点亮使用 v3.2.0 已验证的 selector -> R -> G -> B 完整写入顺序,
# 只在亮/灭边沿访问 MCU,不做高频软件 PWM。 # 只在亮/灭边沿访问 MCU,不做高频软件 PWM。
write_delay_ms = 10 write_delay_ms = 10
@@ -199,7 +225,7 @@ write_delay_ms = 10
# Raspberry Pi 4 板载 PWR / ACT LED。 # Raspberry Pi 4 板载 PWR / ACT LED。
# 本机硬件只支持开/关,不支持真正的 PWM 调暗。 # 本机硬件只支持开/关,不支持真正的 PWM 调暗。
# #
# off = Pigway Pi Control 启动时关闭该灯。 # off = PIGWay Pi Control 启动时关闭该灯。
# 若不希望程序干预,可改为 on。 # 若不希望程序干预,可改为 on。
# #
# 注意: # 注意:
@@ -245,6 +271,12 @@ AWESUN = runawesun.service
# NGINX = nginx.service # NGINX = nginx.service
[service_monitor_disabled]
# Web 中“暂停监控”的状态保存在这里。
# 名称对应 [services] 左侧名称;true 表示保留配置和备注,但 Agent 暂不检查。
# 通常由 Web 自动维护,无需手工编辑。
[processes] [processes]
# ============================================================================= # =============================================================================
# 普通进程监控 # 普通进程监控
+40 -5
View File
@@ -2,6 +2,19 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
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 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 if [ ! -f "$required" ]; then
echo "ERROR: required file missing: $required" >&2 echo "ERROR: required file missing: $required" >&2
@@ -30,12 +43,21 @@ i2c_conflict_pids() {
I2C_CONFLICTS="$(i2c_conflict_pids)" I2C_CONFLICTS="$(i2c_conflict_pids)"
if [ -n "$I2C_CONFLICTS" ]; then if [ -n "$I2C_CONFLICTS" ]; then
echo "ERROR: /dev/i2c-1 is already in use by another process:" >&2 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 while read -r pid; do
ps -p "$pid" -o pid=,user=,comm=,args= >&2 || true ps -p "$pid" -o pid=,user=,comm=,args= >&2 || true
done <<< "$I2C_CONFLICTS" done <<< "$I2C_CONFLICTS"
echo "Stop or reconfigure the conflicting OLED/LED controller, then run the installer again." >&2 if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
exit 1 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 fi
echo "[2/8] 等待 APT/DPKG..." echo "[2/8] 等待 APT/DPKG..."
@@ -81,6 +103,11 @@ path=Path(sys.argv[1])
text=path.read_text() text=path.read_text()
sections={ sections={
"led":( "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), ("cpu_r",255),("cpu_g",0),("cpu_b",0),
("power_r",255),("power_g",40),("power_b",0), ("power_r",255),("power_g",40),("power_b",0),
("memory_r",255),("memory_g",0),("memory_b",255), ("memory_r",255),("memory_g",0),("memory_b",255),
@@ -107,6 +134,10 @@ for section,defaults in sections.items():
if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)] if not re.search(rf"(?m)^\s*{re.escape(key)}\s*=",chunk)]
if missing: if missing:
note="# Added by installer; existing user values are preserved.\n" 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) block=note+"".join(f"{key} = {value}\n" for key,value in missing)
text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n") text=text[:end].rstrip()+"\n"+block+"\n\n"+text[end:].lstrip("\n")
path.write_text(text) path.write_text(text)
@@ -139,8 +170,12 @@ 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 CPU/TMP/DSK" grep -Fq 'f"TMP {temp:.1f}C"' "$APP"
check "UI IP4/IP6" grep -Fq 'ip_label' "$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" check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP"
no_other_i2c_owner() { [ -z "$(i2c_conflict_pids)" ]; } if [ "$FORCE_I2C_CONFLICT" -eq 1 ]; then
check "No other I2C owner" no_other_i2c_owner 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 "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 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 "API/Web version 3.6.0" grep -Fq 'APP_VERSION="3.6.0"' /usr/local/sbin/pigway-pi-control-api
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Pigway Pi Control API and Web Description=PIGWay Pi Control API and Web
After=network.target pigway-pi-control.service After=network.target pigway-pi-control.service
Wants=network.target pigway-pi-control.service Wants=network.target pigway-pi-control.service
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Pigway Raspberry Pi OLED/Fan/RGB Monitor Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
After=multi-user.target network.target After=multi-user.target network.target
Wants=network.target Wants=network.target
+74 -31
View File
File diff suppressed because one or more lines are too long