Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5562fb3e | ||
|
|
03fd6d1cd8 | ||
|
|
8cb71cbe9f | ||
|
|
73907320c8 | ||
|
|
ad25c08eb1 | ||
|
|
5d541d5ef6 | ||
|
|
a8ca071b56 | ||
|
|
419d27b570 |
@@ -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. 结构化事件日志
|
||||
|
||||
@@ -160,6 +162,7 @@ GET /api/v1/health
|
||||
GET /api/v1/status
|
||||
GET /api/v1/logs?limit=200
|
||||
GET /api/v1/config
|
||||
GET /api/v1/services
|
||||
```
|
||||
|
||||
配置写入接口:
|
||||
@@ -172,6 +175,8 @@ Content-Type: application/json
|
||||
{"updates":{"led":{"service_b":"96"},"api":{"port":"6001"}}}
|
||||
```
|
||||
|
||||
服务监控页面会列出本机 systemd 服务,并标识“已监控/未监控”。添加、编辑或删除监控使用 `PUT /api/v1/services`;启动或停止服务使用 `PUT /api/v1/services/control`,两者均要求 Bearer Token。SSH、向日葵、Tailscale 和 PIGWay 自身服务不能从 Web 停止,以免中断远程管理或控制进程。监控备注保存在配置文件的 `[service_notes]`,不会参与硬件 Agent 的监控判断。
|
||||
|
||||
读取 token:
|
||||
|
||||
```bash
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
1d30072ac84d648985cbead711b7cfa55254bc38ce1d48ac71b976333e83126d README.md
|
||||
5442d660875ec28909b1183e4e2aee604e9f2e82d229c3806498392e7a4546e3 README.md
|
||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||
29f59e41052e1e251bf2f38274cdd321fc195602220b61ac8890202128d7c50a app/pigway_pi_control.py
|
||||
6bde03a945b8a8a93694ad4c453a6dcefed1135278ef2cc097029a54046093c1 app/pigway_pi_control_api.py
|
||||
075cc50f9bb46389323c46fc6625fbd59925a2d9755fb1d5a714ea5a94e00908 config/pigway-pi-control.conf
|
||||
0c1a68c6ba447f1c17bd5f7fb66b29a54588084023340ac0b7d959717952bcad install.sh
|
||||
d71d949643d31461f2e62ded3818e1a0f1711d1420756ad0cc32eb04cde0fad3 app/pigway_pi_control.py
|
||||
bf52a12d3208b5330f3e2980187e70330b930e950cfd5165b4e6605febfef65d 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
|
||||
f47627ac7007e54258e3e642358d6829991cbe5469e439fda530d6c9118bea4e web/index.html
|
||||
2a3e37414845e023850af248a8ae4f5aacc06814cee88950e6e2564a34bac2c3 web/index.html
|
||||
|
||||
@@ -253,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)))
|
||||
@@ -441,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.
|
||||
@@ -485,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)},
|
||||
}}
|
||||
@@ -637,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:
|
||||
|
||||
@@ -12,8 +12,13 @@ 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)_[rgb]$")
|
||||
FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency)_flash_(on|off)_ms$")
|
||||
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||
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={"ssh.service","sshd.service","runawesun.service","tailscaled.service",
|
||||
"pigway-pi-control.service","pigway-pi-control-api.service"}
|
||||
|
||||
def load_cfg():
|
||||
cfg=configparser.ConfigParser(); cfg.read(CFG)
|
||||
@@ -36,6 +41,12 @@ def validate_value(section,key,value,existing):
|
||||
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")
|
||||
@@ -94,6 +105,76 @@ def journal(limit):
|
||||
except json.JSONDecodeError: pass
|
||||
return rows
|
||||
|
||||
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":""})
|
||||
item.update({"monitored":True,"monitor_name":name.upper(),"note":notes.get(name,"")})
|
||||
for item in units.values():
|
||||
item.setdefault("monitored",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"}: raise ValueError("operation must be save or delete")
|
||||
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)
|
||||
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)
|
||||
else:
|
||||
if previous_name and previous_name.lower()!=name.lower():
|
||||
change("services",previous_name,None); change("service_notes",previous_name,None)
|
||||
change("services",name,target); change("service_notes",name,note or None)
|
||||
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):
|
||||
server_version="PIGWayAPI/3.6"
|
||||
|
||||
@@ -138,14 +219,28 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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
|
||||
if parsed.path=="/api/v1/services":
|
||||
self.send_json(200,{"services":service_inventory()}); 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
|
||||
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
|
||||
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)
|
||||
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
|
||||
restart_api="api" in updates
|
||||
|
||||
@@ -147,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/频率/温度/散热 = 红色
|
||||
@@ -183,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
|
||||
|
||||
@@ -103,6 +103,11 @@ path=Path(sys.argv[1])
|
||||
text=path.read_text()
|
||||
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),
|
||||
@@ -129,6 +134,10 @@ for section,defaults in sections.items():
|
||||
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)
|
||||
|
||||
+41
-30
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user