Initial Frame Flow read-only Console adapter
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Pigway
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,32 @@
|
||||
# PIGWay Console Adapter — Frame Flow
|
||||
|
||||
独立 ZIP 插件 `pigway-frame-flow` v1.0.0,采用现有 Console adapter protocol v1,仅依赖 Python 标准库。源码在独立仓库 `pigway-console-adapter-frame-flow` 维护,无需修改 Console 核心或部署额外服务。
|
||||
|
||||
## 安装
|
||||
|
||||
1. 在 Console「机器管理」上传 `dist/pigway-frame-flow-1.0.0.zip`,启用插件。
|
||||
2. 添加设备,选择 PIGWay Frame Flow,填写 Console 能访问的 Frame Flow HTTP(S) 根地址(不含 `/api/v1`)和 **Frame Flow API 令牌**,不是 KoKonna Key 或网页登录密码。
|
||||
3. 若使用受限 API 客户端,授予 `devices:read`、`jobs:read`;默认 `FRAME_FLOW_TOKEN` 也可使用。令牌保存于 Console 敏感字段。跨不可信网络使用 HTTPS。
|
||||
4. 查看通用资源看板、告警与历史;默认每 60 秒采集一次。无需自定义前端。
|
||||
|
||||
采集 `/health`、`/ready`、`/api/v1/devices`、`/api/v1/jobs`,不发送写请求、不刷新画框、不执行任务。设备接口可能按 Frame Flow 现有缓存规则查询厂商只读状态。
|
||||
|
||||
服务:就绪状态、设备数、任务数。画框:在线状态、可用时的电量。任务:启用状态和最近终态结果。`FAILED` 与 `UNKNOWN` 分别告警;未运行或执行中不当作成功。停用任务仍显示其最近失败,成功后恢复。设备离线、服务未就绪分别告警。
|
||||
|
||||
`/ready` 的 HTTP 503 作为未就绪状态采集;认证、超时、格式错误或其他采集失败交给 Console 保留旧快照并标记未更新,不伪造离线和零值。完整成功快照才确认告警恢复。历史从安装后的采样开始;设备指标是读取时观察到的 API 状态,不证明物理屏幕已刷新。Mock 服务返回的也是 Mock 设备状态。
|
||||
|
||||
## 本地验证与打包
|
||||
|
||||
在本仓库根目录执行(运行插件无需安装第三方依赖):
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install pytest ruff
|
||||
.venv/bin/python -m pytest -q
|
||||
mkdir -p dist
|
||||
.venv/bin/python package.py plugin dist/pigway-frame-flow-1.0.0.zip
|
||||
```
|
||||
|
||||
开发时可将 `PIGWAY_ADAPTER_PATH` 指向本目录 `plugin` 的绝对路径。ZIP 安装默认停用;生产安装与设备登记需在 Console 中执行。本次验证为本地模拟接口、RPC 和隔离安装,不代表已接入生产。
|
||||
|
||||
Frame Flow API 契约测试为可选集成验证,需要另行安装 Frame Flow 及其依赖。例如相邻项目保留现有虚拟环境时,执行 `PYTHONPATH=../pigway-frame-flow ../pigway-frame-flow/.venv/bin/python -m pytest -q`。独立环境未安装 Frame Flow 时跳过该项,其余测试正常运行。
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package an independently maintained adapter without Console internals."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("directory", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
args = parser.parse_args()
|
||||
root = args.directory.resolve()
|
||||
manifest = json.loads((root / "manifest.json").read_text())
|
||||
entry = (root / manifest["entrypoint"]).resolve()
|
||||
if not entry.is_relative_to(root) or not entry.is_file():
|
||||
parser.error("invalid adapter entrypoint")
|
||||
with ZipFile(args.output, "w", ZIP_DEFLATED) as archive:
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_symlink():
|
||||
parser.error("symlinks are not supported")
|
||||
if (
|
||||
path.is_file()
|
||||
and path.resolve() != args.output.resolve()
|
||||
and not any(part in (".git", "__pycache__") for part in path.parts)
|
||||
and path.suffix != ".pyc"
|
||||
):
|
||||
archive.write(path, path.relative_to(root))
|
||||
print("Adapter package created:", args.output)
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "pigway-frame-flow",
|
||||
"name": "PIGWay Frame Flow",
|
||||
"version": "1.0.0",
|
||||
"protocol_version": 1,
|
||||
"runtime": "python3",
|
||||
"entrypoint": "worker.py",
|
||||
"capabilities": [
|
||||
"status"
|
||||
],
|
||||
"collection": {
|
||||
"interval_seconds": 60
|
||||
},
|
||||
"connection_schema": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"format": "http-origin",
|
||||
"label": {
|
||||
"zh": "Frame Flow 地址",
|
||||
"en": "Frame Flow URL"
|
||||
}
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"secret": true,
|
||||
"label": {
|
||||
"zh": "Frame Flow API 令牌",
|
||||
"en": "Frame Flow API token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only Frame Flow monitoring via Console adapter protocol v1."""
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
MANIFEST = json.loads(Path(__file__).with_name("manifest.json").read_text())
|
||||
OUTPUT_LOCK = threading.Lock()
|
||||
DEVICE_LOCKS = {}
|
||||
LOCK = threading.Lock()
|
||||
MAX_MESSAGE = 16 * 1024 * 1024
|
||||
|
||||
|
||||
class AdapterError(Exception):
|
||||
def __init__(self, code):
|
||||
self.code = code
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def request(device, endpoint):
|
||||
if endpoint not in {"/health", "/ready", "/api/v1/devices", "/api/v1/jobs"}:
|
||||
raise AdapterError("UNSUPPORTED")
|
||||
origin = device.get("connection", {}).get("url", "").rstrip("/")
|
||||
parsed = urlparse(origin)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.path
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise AdapterError("INVALID_CONFIG")
|
||||
token = device.get("credentials", {}).get("token")
|
||||
if not isinstance(token, str) or not token or any(c in token for c in "\r\n"):
|
||||
raise AdapterError("INVALID_CONFIG")
|
||||
headers = {"Accept": "application/json"}
|
||||
if endpoint.startswith("/api/"):
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
try:
|
||||
req = Request(origin + endpoint, headers=headers, method="GET")
|
||||
try:
|
||||
response = build_opener(NoRedirect).open(req, timeout=3)
|
||||
except HTTPError as exc:
|
||||
if endpoint == "/ready" and exc.code == 503:
|
||||
response = exc
|
||||
else:
|
||||
raise AdapterError(
|
||||
"AUTH_FAILED" if exc.code in (401, 403) else "DEVICE_API_ERROR"
|
||||
) from None
|
||||
with response:
|
||||
raw = response.read(2 * 1024 * 1024 + 1)
|
||||
if len(raw) > 2 * 1024 * 1024:
|
||||
raise AdapterError("INVALID_RESPONSE")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
raise AdapterError("INVALID_RESPONSE") from None
|
||||
except (OSError, ValueError):
|
||||
raise AdapterError("DEVICE_UNAVAILABLE") from None
|
||||
|
||||
|
||||
def collect(device):
|
||||
health = request(device, "/health")
|
||||
ready = request(device, "/ready")
|
||||
devices = request(device, "/api/v1/devices")
|
||||
jobs = request(device, "/api/v1/jobs")
|
||||
if (
|
||||
not isinstance(health, dict)
|
||||
or health.get("status") != "ok"
|
||||
or not isinstance(ready, dict)
|
||||
or ready.get("status") not in ("ready", "not_ready")
|
||||
):
|
||||
raise AdapterError("INVALID_RESPONSE")
|
||||
resources = [{"id": "service", "type": "service", "name": "Frame Flow"}]
|
||||
metrics = []
|
||||
alerts = []
|
||||
|
||||
def metric(rid, key, value, unit="boolean"):
|
||||
metrics.append({"resource_id": rid, "key": key, "value": value, "unit": unit})
|
||||
|
||||
def alert(identifier):
|
||||
alerts.append({"id": identifier, "severity": 1})
|
||||
|
||||
metric("service", "service_ready", int(ready["status"] == "ready"))
|
||||
if ready["status"] != "ready":
|
||||
alert("SERVICE_NOT_READY")
|
||||
for items, kind in ((devices, "device"), (jobs, "job")):
|
||||
if not isinstance(items, list) or len(items) > 2000:
|
||||
raise AdapterError("INVALID_RESPONSE")
|
||||
seen = set()
|
||||
for item in items:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or not isinstance(item.get("id"), str)
|
||||
or not item["id"]
|
||||
or item["id"] in seen
|
||||
):
|
||||
raise AdapterError("INVALID_RESPONSE")
|
||||
seen.add(item["id"])
|
||||
rid = kind + ":" + item["id"]
|
||||
resources.append(
|
||||
{
|
||||
"id": rid,
|
||||
"parent_id": "service",
|
||||
"type": kind,
|
||||
"name": item.get("name") or item["id"],
|
||||
}
|
||||
)
|
||||
if kind == "device":
|
||||
if item.get("status") not in ("online", "offline"):
|
||||
raise AdapterError("INVALID_RESPONSE")
|
||||
metric(rid, "device_online", int(item["status"] == "online"))
|
||||
if item["status"] == "offline":
|
||||
alert("DEVICE_OFFLINE:" + item["id"])
|
||||
power = item.get("power") or {}
|
||||
battery = power.get("battery_percent")
|
||||
if type(battery) is int and 0 <= battery <= 100:
|
||||
metric(rid, "battery_percent", battery, "percent")
|
||||
else:
|
||||
if type(item.get("enabled")) is not bool:
|
||||
raise AdapterError("INVALID_RESPONSE")
|
||||
metric(rid, "job_enabled", int(item["enabled"]))
|
||||
result = item.get("last_result")
|
||||
if result in ("COMPLETED", "FAILED", "UNKNOWN"):
|
||||
metric(rid, "last_run_success", int(result == "COMPLETED"))
|
||||
if result in ("FAILED", "UNKNOWN"):
|
||||
alert("JOB_" + result + ":" + item["id"])
|
||||
metric("service", kind + "_count", len(items), "count")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"observed_at": time.time(),
|
||||
"identity": {"reported_name": "Frame Flow", "category": "host"},
|
||||
"collection": MANIFEST["collection"],
|
||||
"capabilities": ["status"],
|
||||
"resources": resources,
|
||||
"metrics": metrics,
|
||||
"alerts": alerts,
|
||||
"alerts_complete": True,
|
||||
}
|
||||
|
||||
|
||||
def dispatch(method, params):
|
||||
if method in ("adapter.initialize", "adapter.describe", "adapter.health"):
|
||||
return MANIFEST
|
||||
if method not in ("device.validate", "device.collect"):
|
||||
raise AdapterError("UNSUPPORTED")
|
||||
device = params["device"]
|
||||
with LOCK:
|
||||
device_lock = DEVICE_LOCKS.setdefault(device["id"], threading.Lock())
|
||||
with device_lock:
|
||||
snapshot = collect(device)
|
||||
return {"valid": True} if method == "device.validate" else snapshot
|
||||
|
||||
|
||||
def handle(message):
|
||||
try:
|
||||
result = dispatch(message["method"], message.get("params", {}))
|
||||
reply = {"jsonrpc": "2.0", "id": message["id"], "result": result}
|
||||
except AdapterError as exc:
|
||||
reply = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"error": {"code": -32000, "data": exc.code},
|
||||
}
|
||||
except Exception:
|
||||
# Do not copy vendor exception messages: they may contain credentials or URLs.
|
||||
reply = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"error": {"code": -32000, "data": "DEVICE_UNAVAILABLE"},
|
||||
}
|
||||
raw = json.dumps(reply, allow_nan=False, separators=(",", ":"))
|
||||
with OUTPUT_LOCK:
|
||||
print(raw, flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
# Bound both running and queued calls even if the parent violates its limits.
|
||||
slots = threading.BoundedSemaphore(8)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
while True:
|
||||
line = sys.stdin.buffer.readline(MAX_MESSAGE + 1)
|
||||
if not line:
|
||||
break
|
||||
if len(line) > MAX_MESSAGE or not line.endswith(b"\n"):
|
||||
break
|
||||
message = json.loads(line)
|
||||
slots.acquire()
|
||||
task = pool.submit(handle, message)
|
||||
task.add_done_callback(lambda _: slots.release())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "pigway-console-adapter-frame-flow"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=8", "ruff>=0.9"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I", "B"]
|
||||
@@ -0,0 +1,170 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"frame_adapter", ROOT / "plugin/worker.py"
|
||||
)
|
||||
worker = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(worker)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint():
|
||||
calls = []
|
||||
data = {
|
||||
"/health": {"status": "ok"},
|
||||
"/ready": {"status": "ready", "checks": {"sqlite": True}},
|
||||
"/api/v1/devices": [
|
||||
{
|
||||
"id": "frame",
|
||||
"name": "Frame",
|
||||
"status": "offline",
|
||||
"power": {"battery_percent": 45},
|
||||
}
|
||||
],
|
||||
"/api/v1/jobs": [{"id": "morning", "enabled": True, "last_result": "FAILED"}],
|
||||
}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
calls.append((self.path, self.headers.get("Authorization")))
|
||||
value = data[self.path]
|
||||
self.send_response(
|
||||
503 if self.path == "/ready" and value["status"] == "not_ready" else 200
|
||||
)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(value).encode())
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever)
|
||||
thread.start()
|
||||
device = {
|
||||
"id": "test",
|
||||
"connection": {"url": f"http://127.0.0.1:{server.server_port}"},
|
||||
"credentials": {"token": "private-test-token"},
|
||||
}
|
||||
yield device, data, calls
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_collect_and_not_ready(endpoint):
|
||||
device, data, calls = endpoint
|
||||
snapshot = worker.collect(device)
|
||||
assert {a["id"] for a in snapshot["alerts"]} == {
|
||||
"DEVICE_OFFLINE:frame",
|
||||
"JOB_FAILED:morning",
|
||||
}
|
||||
assert snapshot["alerts_complete"] is True
|
||||
assert len(snapshot["resources"]) == 3
|
||||
assert all(
|
||||
auth == "Bearer private-test-token"
|
||||
for path, auth in calls
|
||||
if path.startswith("/api/")
|
||||
)
|
||||
data["/ready"]["status"] = "not_ready"
|
||||
assert "SERVICE_NOT_READY" in {a["id"] for a in worker.collect(device)["alerts"]}
|
||||
|
||||
|
||||
def test_no_result_is_not_success(endpoint):
|
||||
device, data, _ = endpoint
|
||||
data["/api/v1/jobs"][0]["last_result"] = None
|
||||
assert not any(
|
||||
m["key"] == "last_run_success" for m in worker.collect(device)["metrics"]
|
||||
)
|
||||
data["/api/v1/devices"] = {}
|
||||
with pytest.raises(worker.AdapterError, match="INVALID_RESPONSE"):
|
||||
worker.collect(device)
|
||||
|
||||
|
||||
def test_rpc_and_write_rejected(endpoint):
|
||||
device, _, _ = endpoint
|
||||
messages = [
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "adapter.initialize",
|
||||
"params": {"protocol_version": 1},
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "device.collect",
|
||||
"params": {"device": device},
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "device.write",
|
||||
"params": {"device": device},
|
||||
},
|
||||
]
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "plugin/worker.py")],
|
||||
input="".join(json.dumps(m) + "\n" for m in messages),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
check=True,
|
||||
)
|
||||
replies = {r["id"]: r for r in map(json.loads, result.stdout.splitlines())}
|
||||
assert replies[1]["result"]["id"] == "pigway-frame-flow"
|
||||
assert replies[2]["result"]["schema_version"] == 1
|
||||
assert replies[3]["error"]["data"] == "UNSUPPORTED"
|
||||
assert "private-test-token" not in result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_auth_and_redirect_are_not_followed(endpoint):
|
||||
from urllib.error import HTTPError
|
||||
|
||||
device, _, _ = endpoint
|
||||
for status, code in ((401, "AUTH_FAILED"), (302, "DEVICE_API_ERROR")):
|
||||
with patch.object(worker, "build_opener") as opener:
|
||||
opener.return_value.open.side_effect = HTTPError(
|
||||
"url", status, "private", {}, None
|
||||
)
|
||||
with pytest.raises(worker.AdapterError, match=code):
|
||||
worker.request(device, "/api/v1/jobs")
|
||||
|
||||
|
||||
def test_current_frame_flow_api_contract(tmp_path):
|
||||
pytest.importorskip("pigway_frame_flow")
|
||||
from fastapi.testclient import TestClient
|
||||
from pigway_frame_flow.api.app import create_app
|
||||
from pigway_frame_flow.settings import Settings
|
||||
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
config_dir=tmp_path / "config",
|
||||
api_token="contract-token",
|
||||
mock=True,
|
||||
)
|
||||
app = create_app(settings, start_worker=False)
|
||||
with TestClient(app) as client:
|
||||
client.headers["Authorization"] = "Bearer contract-token"
|
||||
|
||||
def request(device, path):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
with patch.object(worker, "request", side_effect=request):
|
||||
snapshot = worker.collect({})
|
||||
assert snapshot["alerts_complete"] is True
|
||||
assert any(
|
||||
m["key"] == "service_ready" and m["value"] == 1 for m in snapshot["metrics"]
|
||||
)
|
||||
assert any(r["type"] == "device" for r in snapshot["resources"])
|
||||
Reference in New Issue
Block a user