Compare commits

...
10 changed files with 165 additions and 348 deletions
+27 -41
View File
@@ -1,65 +1,51 @@
# PIGWay Pi Control v3.7.0
# PIGWay Device Agent v3.7.0
独立的 Raspberry Pi / Linux 系统监控与多机 Web 管理平台。提供本机状态、告警、事件日志和多机配置管理,可按需接入独立插件。
[简体中文](README.zh-CN.md) | English
## 功能
- CPU、温度、内存和磁盘容量/用量;主网络接口、IPv4/IPv6、Wi-Fi 信号百分比。
- systemd 服务及进程监控;暂停监控与启停服务是不同操作。
- Raspberry Pi 当前欠压、降频、频率受限、温度限制和根分区只读监控。
- 当前故障统一管理;历史电源位仅记录日志,不生成当前告警。
- 事件日志按日期、严重程度、事件类型和机器查询、排序。
- 多机状态、机器标识、配置与独立的批量配置同步。
- 中英文、跟随系统/深色/浅色主题。
- 独立硬件插件发现、手动接入、暂停联动、断开、能力配置。
PIGWay Device Agent is a lightweight local monitoring service for Raspberry Pi and Linux. It monitors CPU, memory, temperature, disk, networking, services, processes, system health alerts, and journal events. It has no embedded Web UI and does not require a hardware plugin or Device Console.
## 安装
### Installation
```bash
sudo ./install.sh
# 仅安装文件,不改变服务运行状态:
sudo ./install.sh --no-start
# Explicitly enable the API:
sudo ./install.sh --enable-api
# Keep the API disabled:
sudo ./install.sh --disable-api
```
仅需要 Python 3。插件独立安装与运行,主程序不会自动安装或启动插件。
配置 `/etc/pigway-pi-control.conf`;Web 默认端口 6001,可配置。
Interactive installation asks whether to enable the remote API and defaults to no. Non-interactive installation also keeps it disabled unless explicitly enabled. `--no-start` only updates files and configuration; it does not change the current service state.
API 写入令牌在首次安装时生成,保存为 root 可读文件;安装器显示查询方法,不直接打印秘密:
The configuration file is `/etc/pigway-pi-control.conf`. Restart the Agent after changing monitoring settings:
```bash
sudo systemctl restart pigway-pi-control.service
```
### Optional API
The default is `[api] enabled = false`, with no network listener. To use centralized management, enable it and configure the bind address and port, then run:
```bash
sudo systemctl enable --now pigway-pi-control-api.service
sudo cat /etc/pigway-pi-control-api.token
```
服务分别是 `pigway-pi-control.service` 和 `pigway-pi-control-api.service`。
Web 重启不会自动启动已停止的监控。监控与硬件插件的运行状态彼此独立。
The default port is 6001. Every read and write request requires `Authorization: Bearer <token>`. The token belongs only to this device and is not printed directly by the installer. The API exposes health, status, logs, configuration, services, plugins, and plugin configuration under `/api/v1`. It does not serve a Web UI, register multiple machines, or proxy remote requests.
## 已接入插件与扩展
### Console and hardware plugins
[Yahboom 散热板插件](https://tea.pigway.com/way/pigway-cooling-hat) 是独立项目、独立安装包及 systemd 服务。
先在目标机器安装并运行插件,再进入 Web → 系统配置 → 硬件插件 → 发现并接入。
仅发现插件不会接管插件的设备能力。接入后发送当前监控状态,无需开放新的网络端口。
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) can run on a NAS or remote server for dashboards, configuration, and batch synchronization.
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat) independently provides local telemetry, OLED, fan control, and RGB effects.
运行状态卡片仅为已接入插件显示摘要。插件配置根据能力生成:只有风扇就只有风扇设置,有 OLED/RGB 才有相应控件。保存插件配置不重启监控。暂停告警联动或断开接入不会停止插件的本地功能。
The remote API and local plugin integration are independent. After enabling plugin integration, register `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` under `[plugins]` and restart the Agent, or attach it through the Console. Discovery never binds a plugin automatically. Pausing or disconnecting integration does not stop the plugin's local functions.
默认 Unix socket:`/run/pigway-plugins/<id>/api.sock`。协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。插件声明自身能力,Web 据此展示状态和配置。其他设备可通过独立插件实现此协议。
监控和插件互不依赖:插件离线不影响监控、日志和 Web;监控离线不停止插件的本地功能。通信恢复后同步最新状态,过期告警不会继续显示。
当前可用插件:
| 插件 | 独立功能 | 仓库 |
| --- | --- | --- |
| PIGWay Cooling HAT | 本机状态采集、OLED 展示、风扇温控、RGB 灯效 | [pigway-cooling-hat](https://tea.pigway.com/way/pigway-cooling-hat) |
## 运维
### Operations
```bash
sudo systemctl status pigway-pi-control.service pigway-pi-control-api.service
sudo journalctl -u pigway-pi-control.service -f
sudo /usr/local/sbin/pigway-pi-control --diagnose
sudo journalctl -u pigway-pi-control.service -f
```
`--diagnose` 使用同一套监控配置和检查函数,仅通过只读 API 发现插件,不访问硬件总线。
卸载监控不会停止或删除独立插件;卸载插件也不会影响监控。建议先在 Web 断开关联,避免保留离线插件条目。
静态检查:Python 编译、`bash -n install.sh`、`git diff --check`、`sha256sum -c SHA256SUMS`。测试工具不随安装包发布。
Logs follow the system journal retention policy; no additional database is created. Uninstalling one project never uninstalls either of the others. See [Plugin API v1](docs/HARDWARE_PLUGIN_API.md).
+53
View File
@@ -0,0 +1,53 @@
# PIGWay Device Agent v3.7.0
简体中文 | [English](README.md)
极轻的 Raspberry Pi / Linux 本地监控服务:CPU、内存、温度、磁盘、网络、服务/进程、系统健康告警与 journal 日志。无 Web 界面;无需硬件插件,也无需管理中心。
## 安装
```bash
sudo ./install.sh
# 显式启用 API:
sudo ./install.sh --enable-api
# 不启用 API:
sudo ./install.sh --disable-api
```
交互安装询问是否启用远程 API,默认否;非交互安装未指定时也关闭。`--no-start` 仅更新文件和配置,不改变现有服务运行状态,接口开关在下次启动生效。
配置文件:`/etc/pigway-pi-control.conf`。修改监控配置后执行:
```bash
sudo systemctl restart pigway-pi-control.service
```
## 可选 API
默认 `[api] enabled = false`,没有网络监听。需要集中管理时改为 `true`,设置 bind/port,然后:
```bash
sudo systemctl enable --now pigway-pi-control-api.service
sudo cat /etc/pigway-pi-control-api.token
```
默认端口6001。读取及写入都需要 `Authorization: Bearer <token>`。令牌只用于该设备;安装器显示查询方法,不直接显示令牌内容。关闭 API:配置改为 false,执行 `sudo systemctl disable --now pigway-pi-control-api.service`。
API 提供 `/api/v1/health`、`/status`、`/logs`、`/config`、`/services`、`/plugins` 和 `/plugin/config`(均以 `/api/v1` 开头)。不提供网页、多机登记或远程转发。
## 独立管理中心与插件
- [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console):可部署在 NAS 或远端,登记设备地址与令牌,统一看板、配置和批量同步。
- [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat):独立本地采集、OLED、风扇与 RGB。
远程 API 与本地插件联动相互独立。插件启用接入后,可在 `[plugins]` 显式登记 `yahboom-cooling-hat = /run/pigway-plugins/yahboom-cooling-hat/api.sock` 并重启监控,或通过管理中心接入。发现不会自动绑定;暂停/断开联动不停止插件本地功能。
## 运维
```bash
sudo /usr/local/sbin/pigway-pi-control --diagnose
sudo journalctl -u pigway-pi-control.service -f
```
日志依照系统 journal 的保留策略,不额外建库。卸载任何一个项目不会卸载另外两个项目。插件协议见 [API v1](docs/HARDWARE_PLUGIN_API.md)。
+9 -9
View File
@@ -1,11 +1,11 @@
f6bab4766d2b14b14572f25bdf98f2e95ae2759c6b96837343f0713b7e137dea README.md
8372d65296273c034cac7d3a6c702ceb1d8168dac2bf81586fecf2fe57219059 install.sh
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
8b9df5b92218ff1c809d570141b2568228a1a4bdcb041eca29fcc6b51c61889c app/pigway_pi_control.py
339f28978641242436e141add0cc923ed5ebb855bea9f6c9fe949b8eb8579527 app/pigway_pi_control_api.py
7ed31dd9c626c8d3b576fb7976e7c75f2ed1e6f32cbc73c517e608831e440c6e README.md
ddb6c2fb89001f0a7c2736534cc0b125ee4563ea2fc4f8d001752f5866f81e6f README.zh-CN.md
0f1ebcb0e9fa63744fcf0c0045fe4db3fa6eedcf22c333b46d6079d9105fad5c app/pigway_pi_control.py
f91af58e7dd752fa97c168ae419e14e6ee5099ea58d8252568f7ce72affd219f app/pigway_pi_control_api.py
3a8a368bbf299eb2d6cccc968a4aacf070058bddebdc5176083a97ac579d720d app/plugin_api.py
3da8b5d94e2206bffc19e1c4fa493b31880c9a90fd6a02546461fe54a7255dbd config/pigway-pi-control.conf
20c584762cae3a50a498df25e11272c3c191a62c9ce823e63f8224d14637b1d4 systemd/pigway-pi-control-api.service
60665659712a0c7311e7bfa8fd09b300b0ea164cf53746feda91c7cc2a28145d config/pigway-pi-control.conf
e3bf3e7070ea62c0526791bed922688c3fb49fee36ca9c375d080119968feb72 docs/HARDWARE_PLUGIN_API.md
5223c7b71c8d18b3dd9b946a24f398b80afd0bf115e1273bac0f01df8e2ce46d install.sh
5e16c1d5d592fa3e129cde0077da6b089784c82f452205a349d4824f48eb3a66 systemd/pigway-pi-control-api.service
35ec68e1c3040e7affad43961fbb77e9e29f3474b92aed1005502629f9acb549 systemd/pigway-pi-control.service
64c21a4e4d93a68fcca1d0b0126655a56f306a290cced6eed5350ab9d5b5004f web/index.html
73b7922be93bf57169589755137dc5ec7ec7c8059c85c2bf2ee53a344e534d7e docs/HARDWARE_PLUGIN_API.md
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
+16
View File
@@ -53,6 +53,22 @@ def device_details():
except Exception: pass
try: info["model"]=Path("/proc/device-tree/model").read_bytes().rstrip(b"\0").decode()
except Exception: pass
# Device tree identifies ARM boards; DMI covers PCs and many other vendors.
for key,files in {
"manufacturer":["/sys/class/dmi/id/sys_vendor"],
"model":["/sys/firmware/devicetree/base/model","/sys/class/dmi/id/product_name"],
"revision":["/sys/class/dmi/id/product_version","/sys/class/dmi/id/board_version"],
"serial":["/sys/class/dmi/id/product_serial"],
}.items():
if info.get(key):continue
for filename in files:
try:value=Path(filename).read_text().strip().strip("\0")
except (OSError,UnicodeError):continue
if value and value.lower() not in ("none","unknown","default string","to be filled by o.e.m."):
info[key]=value;break
if not info.get("manufacturer"):
try:info["manufacturer"]=Path("/sys/firmware/devicetree/base/compatible").read_bytes().split(b"\0")[0].decode().split(',')[0]
except (OSError,UnicodeError):pass
try:
os_release={}
for line in Path("/etc/os-release").read_text().splitlines():
+15 -164
View File
@@ -14,8 +14,7 @@ APP_VERSION="3.7.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={"device","hosts","services","processes","alerts","timing","dark_mode","api"}
ALLOWED_SECTIONS={"device","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]$")
@@ -65,60 +64,6 @@ def machine_identity():
if name.lower()=="auto": name=identifier
return {"id":identifier,"name":name,"hostname":socket.gethostname(),"local":True,"url":""}
def normalize_url(value):
parsed=urlparse(str(value).strip())
if parsed.scheme not in {"http","https"} or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("host URL must be an http(s) URL without credentials")
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
def host_registry():
local=machine_identity(); result=[local]
cfg=load_cfg()
if cfg.has_section("hosts"):
for host_id,url in cfg.items("hosts"):
try: base=normalize_url(url)
except ValueError: continue
if host_id==local["id"]: continue
result.append({"id":host_id,"name":host_id,"hostname":"","local":False,"url":base})
return result
def find_host(host_id):
for host in host_registry():
if host["id"]==host_id:return host
raise ValueError("unknown host")
def remote_json(base,path,method="GET",payload=None,token=""):
data=None if payload is None else json.dumps(payload,separators=(",",":")).encode()
headers={"Accept":"application/json"}
if data is not None: headers["Content-Type"]="application/json"
if token: headers["Authorization"]=f"Bearer {token}"
request=Request(base+path,data=data,headers=headers,method=method)
with urlopen(request,timeout=5) as response:
return json.loads(response.read())
def fleet_status():
hosts=host_registry(); results=[]
try:
status=json.loads(STATUS.read_text())
status.setdefault("machine",machine_identity())
results.append({"host":hosts[0],"online":True,"status":status})
except Exception as exc:
results.append({"host":hosts[0],"online":False,"error":str(exc)})
def fetch(host):
try:
status=remote_json(host["url"],"/api/v1/status")
machine=status.get("machine",{})
merged={**host,"name":machine.get("name") or host["name"],"hostname":machine.get("hostname","")}
return {"host":merged,"online":True,"status":status}
except Exception as exc:return {"host":host,"online":False,"error":str(exc)}
peers=hosts[1:]
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
futures=[pool.submit(fetch,h) for h in peers]
for future in as_completed(futures): results.append(future.result())
order={h["id"]:i for i,h in enumerate(hosts)}
results.sort(key=lambda x:order.get(x["host"]["id"],999))
return {"hosts":results,"timestamp":int(time.time())}
def load_cfg():
cfg=configparser.ConfigParser(); cfg.read(CFG)
return cfg
@@ -132,8 +77,11 @@ def config_json():
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=="api" and key=="enabled":
if value.lower() not in {"true","false"}:raise ValueError("enabled must be true or false")
return
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()):
if section not in {"services","processes"} and key not in existing.get(section,set()):
raise ValueError(f"unsupported option: {section}.{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")
@@ -163,8 +111,7 @@ def validate_value(section,key,value,existing):
ipaddress.ip_address(value)
elif section=="device" and key=="identifier" and value.lower()!="auto" and not NAME_RE.fullmatch(value):
raise ValueError("device.identifier must be auto or 1..64 letters, numbers, dot, underscore or dash")
elif section=="hosts":
normalize_url(value)
def replace_ini_value(section,key,value):
text=CFG.read_text()
@@ -172,7 +119,7 @@ def replace_ini_value(section,key,value):
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)^\[.+\][ \t]*$",text[section_match.end():])
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",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)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
@@ -189,15 +136,6 @@ def save_ini_text(text,prefix="api"):
temporary=CFG.with_suffix(".tmp"); temporary.write_text(text); temporary.chmod(0o644); temporary.replace(CFG)
return str(backup)
def edit_host(operation,host_id,url=""):
if operation not in {"save","delete"}: raise ValueError("invalid host operation")
if not NAME_RE.fullmatch(host_id): raise ValueError("invalid host identifier")
local=machine_identity()["id"]
if host_id==local: raise ValueError("local host cannot be changed in the remote host list")
if operation=="save": url=normalize_url(url)
text=replace_ini_value("hosts",host_id,url if operation=="save" else None)
return save_ini_text(text,"hosts")
def update_ini(updates):
if not isinstance(updates,dict): raise ValueError("updates must be an object")
text=CFG.read_text()
@@ -217,7 +155,7 @@ def update_ini(updates):
if not section_match:
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
continue
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",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)
@@ -268,50 +206,6 @@ def journal_query(since=None,until=None,severity="",event="",search="",sort="tim
return {"logs":rows[offset:offset+limit],"total":total,"offset":offset,"limit":limit,
"event_types":event_types,"retention":"systemd-journal"}
def fleet_journal_query(since=None,until=None,severity="",event="",search="",machine="",
sort="timestamp",order="desc",limit=100,offset=0):
hosts=host_registry(); rows=[]; event_types=set(); machines=[]
def decorate(payload,host):
machine_info=payload.get("machine") or {"id":host["id"],"name":host["name"]}
host_id=machine_info.get("id") or host["id"]
host_name=machine_info.get("name") or host["name"]
decorated=[]
for row in payload.get("logs",[]):
decorated.append({**row,"machine_id":host_id,"machine_name":host_name})
return decorated,set(payload.get("event_types",[])),{"id":host_id,"name":host_name}
local_payload=journal_query(since,until,severity,event,search,"timestamp","desc",50000,0)
local_payload["machine"]=machine_identity()
local_rows,local_events,local_machine=decorate(local_payload,hosts[0])
rows.extend(local_rows); event_types.update(local_events); machines.append(local_machine)
query={"limit":50000,"offset":0,"sort":"timestamp","order":"desc"}
for key,value in (("since",since),("until",until),("severity",severity),("event",event),("search",search)):
if value not in (None,""): query[key]=value
def fetch(host):
payload=remote_json(host["url"],"/api/v1/logs?"+urlencode(query))
try:
status=remote_json(host["url"],"/api/v1/status")
payload["machine"]=status.get("machine",{})
except Exception: pass
return decorate(payload,host)
peers=hosts[1:]
with ThreadPoolExecutor(max_workers=min(8,max(1,len(peers)))) as pool:
futures=[pool.submit(fetch,h) for h in peers]
for future in as_completed(futures):
try:
remote_rows,remote_events,remote_machine=future.result()
rows.extend(remote_rows); event_types.update(remote_events); machines.append(remote_machine)
except Exception: pass
if machine: rows=[row for row in rows if row["machine_id"]==machine]
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"],
"machine":lambda x:(x["machine_name"],x["machine_id"])}
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":sorted(event_types),"machines":sorted(machines,key=lambda x:(x["name"],x["id"])),
"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 {}
@@ -356,7 +250,7 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
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)^\[.+\][ \t]*$",text[section_match.end():])
next_section=re.search(r"(?m)^\[[^\]\r\n]+\]",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)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
@@ -422,19 +316,13 @@ class Handler(BaseHTTPRequestHandler):
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 not self.authorized():self.send_json(401,{"error":"bearer token required"});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/hosts":
self.send_json(200,{"hosts":host_registry()}); return
if parsed.path=="/api/v1/fleet/status":
self.send_json(200,fleet_status()); return
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 in {"/api/v1/logs","/api/v1/fleet/logs"}:
if parsed.path=="/api/v1/logs":
query=parse_qs(parsed.query)
value=lambda key,default="":query.get(key,[default])[0]
limit=min(max(int(value("limit",api_setting("log_limit",200,int))),1),50000)
@@ -444,33 +332,23 @@ class Handler(BaseHTTPRequestHandler):
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")
allowed_sort={"timestamp","severity","event","message"}
if parsed.path=="/api/v1/fleet/logs": allowed_sort.add("machine")
if sort not in allowed_sort: 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")
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"]:
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/config")); return
self.send_json(200,{"config":config_json(),"write_requires_token":True,"machine":machine_identity()}); return
if parsed.path=="/api/v1/services":
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
if host_id and host_id!=machine_identity()["id"]:
host=find_host(host_id); self.send_json(200,remote_json(host["url"],"/api/v1/services")); return
self.send_json(200,{"services":service_inventory(),"machine":machine_identity()}); return
self.send_json(404,{"error":"not found"})
except ValueError as e: self.send_json(400,{"error":str(e)})
@@ -478,44 +356,15 @@ class Handler(BaseHTTPRequestHandler):
def do_PUT(self):
path=urlparse(self.path).path
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 path not in {"/api/v1/plugins","/api/v1/plugin/config","/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()
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
if path=="/api/v1/fleet/config":
updates=body.get("updates"); targets=body.get("targets",[]); tokens=body.get("tokens",{})
if not isinstance(targets,list) or not targets or len(targets)>32: raise ValueError("targets must contain 1..32 host identifiers")
if not isinstance(tokens,dict): raise ValueError("tokens must be an object")
results=[]; local_id=machine_identity()["id"]; restart_local_api=False
for host_id in dict.fromkeys(str(x) for x in targets):
try:
if host_id==local_id:
backup=update_ini(updates)
subprocess.run(["systemctl","restart","pigway-pi-control.service"],check=True,timeout=10)
restart_local_api="api" in updates
results.append({"id":host_id,"ok":True,"backup":backup})
else:
host=find_host(host_id); token=str(tokens.get(host_id,""))
if not token: raise ValueError("remote bearer token required")
response=remote_json(host["url"],"/api/v1/config","PUT",{"updates":updates},token)
results.append({"id":host_id,"ok":True,"backup":response.get("backup","")})
except Exception as exc: results.append({"id":host_id,"ok":False,"error":str(exc)})
self.send_json(200,{"ok":all(x["ok"] for x in results),"results":results})
if restart_local_api: threading.Timer(0.2,self.server.shutdown).start()
return
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)
@@ -537,6 +386,8 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e: self.send_json(500,{"error":str(e)})
def main():
if not load_cfg().getboolean("api","enabled",fallback=False):
print("API_DISABLED",flush=True);sys.exit(78)
host=api_setting("bind","0.0.0.0")
port=api_setting("port",6001,int)
server=ThreadingHTTPServer((host,port),Handler)
+3 -8
View File
@@ -13,15 +13,10 @@ identifier = auto
name = auto
[hosts]
# 在作为聚合入口的机器上登记其他节点,每行格式:
# 唯一标识 = http://节点IP:6001
# 示例:
# living-room = http://192.168.1.20:6001
[api]
# API 与 Web 使用同一监听地址和端口。0.0.0.0 允许局域网访问;
# 默认关闭远程 API。开启后所有请求均需令牌;本地监控和插件联动不受此开关影响。
enabled = false
# API 监听地址和端口。0.0.0.0 允许局域网访问;
# 如只允许本机访问可改为 127.0.0.1。修改后重启 API 服务生效。
bind = 0.0.0.0
port = 6001
+10
View File
@@ -79,3 +79,13 @@ fallback; actual execution still depends on functioning hardware.
Stopping a monitor, pausing its link, or unlinking a plugin does not stop the
plugin service. No API/Web service has a systemd Wants dependency that starts the
other application. Do not run two drivers for the same MCU, GPIO or fan.
## Optional listeners
Agent network API defaults to disabled (`[api] enabled=false`) and has no Web UI.
When enabled, every request requires a device bearer token.
Hardware plugin integration defaults to disabled (`[integration] enabled=false`):
no Unix API socket is created, while local telemetry and hardware workers continue.
Change integration locally and restart the plugin. Monitor-to-plugin linkage does
not require the Agent network API. The independent Web Manager stores registered
device addresses and tokens; it is not implicitly a monitored device.
+29 -8
View File
@@ -2,22 +2,24 @@
set -euo pipefail
cd "$(dirname "$0")"
NO_START=0
API_ENABLED=""
for arg in "$@"; do
case "$arg" in
--enable-api) API_ENABLED=true ;;
--disable-api) API_ENABLED=false ;;
--no-start) NO_START=1 ;;
-h|--help) echo 'Usage: sudo ./install.sh [--no-start]'; exit 0 ;;
-h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--enable-api|--disable-api]'; exit 0 ;;
*) echo "ERROR: unknown option: $arg (hardware is installed separately)" >&2; exit 2 ;;
esac
done
for required in app/pigway_pi_control.py app/plugin_api.py app/pigway_pi_control_api.py 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/plugin_api.py app/pigway_pi_control_api.py config/pigway-pi-control.conf systemd/pigway-pi-control.service systemd/pigway-pi-control-api.service; do
[ -f "$required" ] || { echo "ERROR: required file missing: $required" >&2; exit 1; }
done
[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run installer as root' >&2; exit 1; }
# The monitor has no I2C, GPIO, Pillow, smbus2 or cooling-board requirement.
command -v python3 >/dev/null || { apt-get update; apt-get install -y python3; }
mkdir -p /usr/local/share/pigway-pi-control/web
mkdir -p /usr/local/share/pigway-pi-control
install -m 0644 app/plugin_api.py /usr/local/share/pigway-pi-control/plugin_api.py
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
if [ -f /etc/pigway-pi-control.conf ]; then
@@ -32,7 +34,7 @@ from pathlib import Path
import configparser
p=Path('/etc/pigway-pi-control.conf');c=configparser.ConfigParser();c.read(p)
with p.open('a') as f:
for section,values in {'device':{'identifier':'auto','name':'auto'},'hosts':{},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
for section,values in {'device':{'identifier':'auto','name':'auto'},'plugins':{},'api':{'bind':'0.0.0.0','port':'6001','log_limit':'200'}}.items():
if not c.has_section(section):
f.write('\n['+section+']\n'+''.join(k+' = '+v+'\n' for k,v in values.items()))
PY
@@ -42,12 +44,31 @@ fi
chmod 0600 /etc/pigway-pi-control-api.token
install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/
install -m 0644 systemd/pigway-pi-control-api.service /etc/systemd/system/
if [ -z "$API_ENABLED" ]; then
API_ENABLED=false
if [ -t 0 ]; then
read -r -p 'Enable remote management API? [y/N] ' answer
case "$answer" in y|Y|yes|YES) API_ENABLED=true ;; esac
fi
fi
API_ENABLED="$API_ENABLED" python3 - <<'PYAPI'
import os,sys
sys.path.insert(0,'app')
import pigway_pi_control_api as api
api.save_ini_text(api.replace_ini_value('api','enabled',os.environ['API_ENABLED']),'install')
PYAPI
systemctl daemon-reload
if [ "$NO_START" -eq 0 ]; then
systemctl enable pigway-pi-control.service pigway-pi-control-api.service >/dev/null
systemctl restart pigway-pi-control.service pigway-pi-control-api.service
systemctl enable pigway-pi-control.service >/dev/null
systemctl restart pigway-pi-control.service
systemctl is-active --quiet pigway-pi-control.service
systemctl is-active --quiet pigway-pi-control-api.service
if [ "$API_ENABLED" = true ]; then
systemctl enable pigway-pi-control-api.service >/dev/null
systemctl restart pigway-pi-control-api.service
systemctl is-active --quiet pigway-pi-control-api.service
else
systemctl disable --now pigway-pi-control-api.service
fi
fi
echo 'Monitor installed. Hardware plugins are installed and connected separately.'
echo 'Config: /etc/pigway-pi-control.conf'
+3 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=PIGWay Pi Control API and Web
Description=PIGWay Device Agent optional local API
After=network.target pigway-pi-control.service
Wants=network.target
@@ -7,6 +7,8 @@ Wants=network.target
Type=simple
ExecStart=/usr/local/sbin/pigway-pi-control-api
Restart=always
RestartPreventExitStatus=78
SuccessExitStatus=78
RestartSec=2
User=root
NoNewPrivileges=true
-117
View File
File diff suppressed because one or more lines are too long