From f42ea9d4bde19a60cd1516994934c44218e0b270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 10:21:12 +0800 Subject: [PATCH] feat: add lightweight API and web console --- README.md | 63 ++++++++-- SHA256SUMS | 13 +- app/pigway_pi_control.py | 43 ++++++- app/pigway_pi_control_api.py | 166 ++++++++++++++++++++++++++ config/pigway-pi-control.conf | 10 +- install.sh | 61 +++++++--- systemd/pigway-pi-control-api.service | 17 +++ uninstall.sh | 5 +- web/index.html | 40 +++++++ 9 files changed, 382 insertions(+), 36 deletions(-) create mode 100644 app/pigway_pi_control_api.py create mode 100644 systemd/pigway-pi-control-api.service create mode 100644 web/index.html diff --git a/README.md b/README.md index 2dbaeab..9632953 100644 --- a/README.md +++ b/README.md @@ -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 @@ -82,7 +82,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 +136,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 +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 服务也会自动重启。 + ## 用户配置 正式配置只有一个: @@ -165,21 +209,24 @@ sudo ./install.sh - 检查 I²C - 禁用旧 `temp-control.service` - 安装字体、程序、配置和 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 管理界面** diff --git a/SHA256SUMS b/SHA256SUMS index 63e3e8a..7902c79 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,10 @@ -dfe901d5f7e3a8ef897203c38cf9e4d47eca63e0d718f74b99102c874edfc3b3 README.md +e43448e7a3d6291aa06717069cf1a3dcbb3fba71fce709f17292d61f89543b43 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -43aca7bda6e8edb59734e06ccd373a9426c73fbf359727a906b6e2ca405fc344 app/pigway_pi_control.py -02e67b147ae68378bed1dcb88245b636c72fba3dabe4c119aab61219eee89995 config/pigway-pi-control.conf -d84774f2fe2bfe380b7b29d83d42ddc17938fb03f78a746df07fc2e49f635e64 install.sh +29f59e41052e1e251bf2f38274cdd321fc195602220b61ac8890202128d7c50a app/pigway_pi_control.py +f32be5b63fe2be51e39c106c232ae3cc14dab7c0c3719e2609b9e93a7802b6c3 app/pigway_pi_control_api.py +9f3b2be557b3cd93c309f8a1cadce7d34b23ca06c1ff69231d934e8180fefb48 config/pigway-pi-control.conf +f54b7d0f8c19052efb7b305b074c88aa110483160d2029b3ce44b4d1a15c3f59 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service -a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh +5d7562c496de597f61b62af48f8f6350ad270e1ebf725426d9f1e5b5d698055e systemd/pigway-pi-control-api.service +9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh +4b4066d104202f664f5df07c97d4e256dde32b5f4426e6a955a3ecef5e08df60 web/index.html diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 0b91c5e..a33f5b2 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -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(*_): @@ -616,6 +617,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(): @@ -660,6 +672,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 +703,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 +766,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: diff --git a/app/pigway_pi_control_api.py b/app/pigway_pi_control_api.py new file mode 100644 index 0000000..290c150 --- /dev/null +++ b/app/pigway_pi_control_api.py @@ -0,0 +1,166 @@ +#!/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)_[rgb]$") +FLASH_KEY_RE=re.compile(r"^(warning|critical|emergency)_flash_(on|off)_ms$") + +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=="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() diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 3561691..af7298a 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -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 / 内存 / 温度 / 磁盘以及风扇控制按此周期更新。 diff --git a/install.sh b/install.sh index db09a74..6b89464 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,7 @@ 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 +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 @@ -37,26 +37,29 @@ 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":( ("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,33 +69,49 @@ 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" + 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" @@ -102,6 +121,9 @@ 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 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 +131,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" diff --git a/systemd/pigway-pi-control-api.service b/systemd/pigway-pi-control-api.service new file mode 100644 index 0000000..c326c7a --- /dev/null +++ b/systemd/pigway-pi-control-api.service @@ -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 diff --git a/uninstall.sh b/uninstall.sh index ed9eb74..b13fd5c 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -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 已保留。" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..68e8daa --- /dev/null +++ b/web/index.html @@ -0,0 +1,40 @@ + + + + +Pigway Pi Control + + +
+

Pigway Pi Control

正在连接 Raspberry Pi…
离线
+ +
+
+
CPU
--
--
+
温度
--
--
+
内存
--
+
磁盘
--
+
网络
--
--
+
显示控制
--
--
+
+

当前告警

无告警
+

服务与进程

+
+ + +
+