refactor: separate monitoring from hardware plugins

This commit is contained in:
way
2026-09-27 20:24:34 +08:00
parent a976dfa3fa
commit 993d95bab1
12 changed files with 458 additions and 1128 deletions
+76 -12
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
import sys
sys.path.append("/usr/local/share/pigway-pi-control")
from plugin_api import discover,registered,request as plugin_request,socket_path
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
from concurrent.futures import ThreadPoolExecutor,as_completed
from http import HTTPStatus
@@ -12,7 +15,7 @@ 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={"device","hosts","services","processes","alerts","fan","led","timing","dark_mode","display","api"}
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"}
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
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]$")
@@ -21,6 +24,40 @@ NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorf
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
PROTECTED_UNITS={"pigway-pi-control-api.service"}
def plugin_inventory():
cfg=load_cfg();items=discover(cfg)
for item in items:item["paused"]=cfg.getboolean("plugin_paused",item["id"],fallback=False)
return items
def find_plugin(plugin_id):
for item in plugin_inventory():
if item["id"]==plugin_id:return item
raise ValueError("unknown plugin")
def plugin_operation(body):
plugin_id=str(body.get("id",""))
if not NAME_RE.fullmatch(plugin_id):raise ValueError("invalid plugin id")
item=find_plugin(plugin_id);operation=body.get("operation")
if operation=="link":
if not item["online"]:raise ValueError("plugin is offline")
text=replace_ini_value("plugins",plugin_id,socket_path(item["socket"]))
save_ini_text(text,"plugin-link")
text=replace_ini_value("plugin_paused",plugin_id,"false")
elif operation in {"pause","resume","unlink"}:
if not item["linked"]:raise ValueError("plugin is not linked")
text=replace_ini_value("plugins",plugin_id,None) if operation=="unlink" else replace_ini_value("plugin_paused",plugin_id,"true" if operation=="pause" else "false")
else:raise ValueError("invalid plugin operation")
backup=save_ini_text(text,"plugin")
# Only reload a monitor that is already running. Never start hardware here.
subprocess.run(["systemctl","try-restart","pigway-pi-control.service"],check=True,timeout=15)
if operation in {"pause","unlink"}:
try:plugin_request(item["socket"],"DELETE","/v1/state",{})
except Exception:pass # An offline plugin expires its lease independently.
return {"ok":True,"backup":backup}
def machine_identity():
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
@@ -92,12 +129,19 @@ def api_setting(key,default,cast=str):
def config_json():
cfg=load_cfg()
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
return {section:{k:v for k,v in cfg.items(section) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
def validate_value(section,key,value,existing):
if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin")
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
raise ValueError(f"unsupported option: {section}.{key}")
if RGB_KEY_RE.fullmatch(key):
if section=="hardware" and key=="cooling_hat_enabled":
if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false")
elif section=="led" and key=="alert_mode":
if value.lower() not in ("breathe","flash"): raise ValueError("alert_mode must be breathe or flash")
elif section=="led" and key=="custom_min_hold_ms":
if not 1500<=int(value)<=60000: raise ValueError("custom_min_hold_ms must be 1500..60000")
elif 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):
@@ -124,14 +168,14 @@ def validate_value(section,key,value,existing):
def replace_ini_value(section,key,value):
text=CFG.read_text()
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
if not section_match:
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
return text
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
next_section=re.search(r"(?m)^\[.+\][ \t]*$",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)
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
if key_match:
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
return text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
@@ -169,11 +213,11 @@ def update_ini(updates):
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)
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",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():])
next_section=re.search(r"(?m)^\[.+\][ \t]*$",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)
@@ -308,14 +352,14 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
text=CFG.read_text()
def change(section,key,value):
nonlocal text
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",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():])
next_section=re.search(r"(?m)^\[.+\][ \t]*$",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)
key_match=re.search(rf"(?mi)^[ \t]*{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:]
@@ -408,6 +452,16 @@ class Handler(BaseHTTPRequestHandler):
if parsed.path=="/api/v1/fleet/logs":
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
plugin_id=query.get("id",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id)
self.send_json(200,remote_json(host["url"],parsed.path+"?"+urlencode({"id":plugin_id})));return
if parsed.path.endswith("/plugins"):
self.send_json(200,{"plugins":plugin_inventory()});return
item=find_plugin(plugin_id)
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
if parsed.path=="/api/v1/config":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
@@ -424,10 +478,20 @@ class Handler(BaseHTTPRequestHandler):
def do_PUT(self):
path=urlparse(self.path).path
if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/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()
if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
host_id=str(body.get("host",""))
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id);token=str(body.get("target_token",""))
if not token:raise ValueError("remote bearer token required")
payload={k:v for k,v in body.items() if k not in {"host","target_token"}}
self.send_json(200,remote_json(host["url"],path,"PUT",payload,token));return
if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
item=find_plugin(str(body.get("id","")))
self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
if path=="/api/v1/hosts":
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return