Isolate official account RPC capabilities from API key device driver

This commit is contained in:
Pigway
2026-10-03 07:03:35 +08:00
parent 142d771ffa
commit 5aa0e9f762
15 changed files with 434 additions and 126 deletions
@@ -0,0 +1 @@
"""Optional integrations, independent of device drivers."""
@@ -0,0 +1,23 @@
# 官方能力模块 / Official capabilities
Optional official-account integration, independent of the KoKonna device API Key
client. The application composition root attaches it per configured device; the
FrameDeviceDriver protocol does not depend on account login or widgets.
- `rpc.py`: in-memory official login/session, disconnect, device discovery/detail
and widget query. Strict method allowlist, no retries, no raw vendor errors.
- `widgets.py`: normalized widget states and 60-second successful query cache.
- `capabilities.py`: full discovered client operation inventory and evidence.
Discovered does not mean implemented or enabled. Unenabled operations are blocked.
- `widget_catalog.json`: dated, non-secret fallback catalog.
The public `/api/v1/devices/{device_id}/widgets` endpoint is unchanged. The old
`drivers.kokonna.widgets.WidgetReader` import remains a compatibility re-export.
No raw RPC proxy or public password-login endpoint is introduced. Future admin
account connection UI must use this module and keep credentials server-side.
No account or session secret is automatically persisted by this module.
设备 API Key 仍负责现有设备、图片和图库接口。官方账号功能集中在本目录,
不进入 DisplayService、Renderer、Scheduler 或 FrameDeviceDriver。
账号登录已提供内部客户端能力;页面连接入口、写操作和会话持久化尚未实现。
所有已发现但未启用的 RPC 方法均拒绝执行,不复用 API Key 的写入开关。
@@ -0,0 +1 @@
"""Optional KoKonna official-account capabilities; no device API Key dependency."""
@@ -0,0 +1,109 @@
"""Discovered RPC operations, not an authorization or live support guarantee."""
from dataclasses import dataclass
@dataclass(frozen=True)
class OfficialCapability:
method: str
implemented: bool
effect: str
evidence: str
READ_OPERATIONS = frozenset(
{"app.getRobots", "appFrame.getFrame", "appFrame.getFrameCovers"}
)
DISCOVERED_METHODS = (
"app.addFriendRobot",
"app.addShareRobot",
"app.bindRobot",
"app.changePassword",
"app.cleanMemory",
"app.createCustomRole",
"app.createPointOrder",
"app.deleteFriendRobot",
"app.forgetPassword",
"app.getChatHistory",
"app.getCustomRoleInfo",
"app.getDistributors",
"app.getFirmwares",
"app.getPayOrder",
"app.getPayOrderList",
"app.getPoints",
"app.getRedeem",
"app.getRobot",
"app.getRobotByUUID",
"app.getRobots",
"app.getUserInfo",
"app.login",
"app.redeemRobotPointByCode",
"app.register",
"app.removeShare",
"app.sendChat",
"app.sendCtrl",
"app.setRobotNetwork",
"app.setTimezone",
"app.setUpgradeFirmware",
"app.testCustomRoleVoice",
"app.unlink",
"app.updateCustomRole",
"app.updateRobot",
"app.wxlogin",
"appFrame.addFrameArtist",
"appFrame.addFrameGalleryImage",
"appFrame.addTag",
"appFrame.changeFrameImageName",
"appFrame.changeName",
"appFrame.chat",
"appFrame.deleteAllImage",
"appFrame.deleteImage",
"appFrame.displayImage",
"appFrame.enablePair",
"appFrame.enableShareCode",
"appFrame.getFrame",
"appFrame.getFrameArtists",
"appFrame.getFrameCovers",
"appFrame.getFrameGalleryImages",
"appFrame.getFrameGallerys",
"appFrame.getFrameSplashs",
"appFrame.getImages",
"appFrame.getShareImages",
"appFrame.importImagesByTransferToken",
"appFrame.likeImage",
"appFrame.recognizeVoice",
"appFrame.refreshImage",
"appFrame.refreshShareUUID",
"appFrame.removeTag",
"appFrame.reportFrameImage",
"appFrame.reset",
"appFrame.setFrameCover",
"appFrame.setFrameImageChangeInterval",
"appFrame.setFrameImageChangeMinute",
"appFrame.setFrameImageChangeTags",
"appFrame.setFrameImageChangeType",
"appFrame.setImageTags",
"appFrame.setRotate",
"appFrame.setSplash",
"appFrame.shareImage",
"appFrame.text2speech",
"appFrame.uploadImage",
)
def capabilities() -> tuple[OfficialCapability, ...]:
return tuple(
OfficialCapability(
method=method,
implemented=method in READ_OPERATIONS or method == "app.login",
effect="read"
if method in READ_OPERATIONS
else "authentication"
if method == "app.login"
else "unverified",
evidence="live_verified"
if method in READ_OPERATIONS or method == "app.login"
else "apk_discovered",
)
for method in DISCOVERED_METHODS
)
@@ -0,0 +1,82 @@
"""Strict account RPC allowlist. Never falls back to a device key or retries."""
import time
import requests
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.integrations.kokonna_official.capabilities import READ_OPERATIONS
class OfficialRPC:
def __init__(self, token: str = ""):
self._token = token
@property
def authorized(self) -> bool:
return bool(self._token)
def _call(self, method: str, params: dict) -> dict:
if method not in READ_OPERATIONS and method != "app.login":
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Official operation is not enabled", 422
)
if method != "app.login" and not self._token:
raise FlowError(
E.CONFIG_REQUIRED, "Official account authorization required", 503
)
try:
response = requests.post(
"https://robot.galaxyguide.cn/api/rpc",
headers={"Authorization": "Bearer " + self._token}
if method != "app.login"
else {},
json={
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": int(time.time() * 1000),
},
timeout=15,
allow_redirects=False,
)
if response.status_code in (401, 403):
raise FlowError(
E.UNAUTHORIZED, "Official account authentication required", 401
)
payload = response.json()
error = payload.get("error")
if isinstance(error, dict) and error.get("message") == "请重新登陆":
raise FlowError(E.UNAUTHORIZED, "Official account session expired", 401)
response.raise_for_status()
if error or not isinstance(payload.get("result"), dict):
raise ValueError()
return payload["result"]
except FlowError:
raise
except (requests.RequestException, ValueError, AttributeError):
raise FlowError(
E.CLOUD_UNAVAILABLE, "Official service query failed", 502
) from None
def login(self, username: str, password: str) -> None:
if not username or not password:
raise FlowError(E.INVALID_REQUEST, "Official account credentials required")
result = self._call("app.login", {"username": username, "password": password})
token = result.get("token")
if not isinstance(token, str) or not token:
raise FlowError(E.UNAUTHORIZED, "Official account login failed", 401)
self._token = token
def disconnect(self) -> None:
self._token = ""
def devices(self) -> dict:
return self._call("app.getRobots", {})
def device(self, robot_id: int) -> dict:
return self._call("appFrame.getFrame", {"id": robot_id})
def widgets(self, robot_id: int) -> dict:
return self._call("appFrame.getFrameCovers", {"robotId": robot_id})
@@ -0,0 +1,862 @@
{
"observed_at": "2026-10-02",
"transport": "https://robot.galaxyguide.cn/api/rpc",
"method": "appFrame.getFrameCovers",
"catalog": [
{
"id": 188,
"name": "大字体日历",
"tags": [
"Weather",
"feature",
"Calendar"
],
"type": null,
"arg_keys": [
"城市"
]
},
{
"id": 183,
"name": "天气日历-黑",
"tags": [
"Weather",
"feature",
"Calendar"
],
"type": null,
"arg_keys": [
"城市"
]
},
{
"id": 182,
"name": "天气日历-白",
"tags": [
"Weather",
"feature",
"Calendar"
],
"type": null,
"arg_keys": [
"城市"
]
},
{
"id": 190,
"name": "文学金句2",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 30,
"name": "文学金句",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 191,
"name": "名人名言",
"tags": [
"DailyRandom",
"Calendar",
"feature",
"Lunar"
],
"type": null,
"arg_keys": []
},
{
"id": 29,
"name": "古诗词名句",
"tags": [
"DailyRandom",
"Calendar",
"feature",
"Lunar"
],
"type": null,
"arg_keys": []
},
{
"id": 2,
"name": "电影台词",
"tags": [
"Weather",
"Calendar",
"DailyRandom"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 13,
"name": "一周天气",
"tags": [
"Weather",
"feature",
"Calendar",
"DailyRandom"
],
"type": null,
"arg_keys": [
"城市"
]
},
{
"id": 27,
"name": "世界名画推荐",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 7,
"name": "项目倒计时",
"tags": [
"Countdown"
],
"type": null,
"arg_keys": [
"项目名称",
"截止时间"
]
},
{
"id": 8,
"name": "结婚纪念日",
"tags": [
"Stopwatch"
],
"type": null,
"arg_keys": [
"新郎姓名",
"新娘姓名",
"结婚日期"
]
},
{
"id": 28,
"name": "乐器知识",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 193,
"name": "作品名称和描述",
"tags": [
"DailyRandom"
],
"type": null,
"arg_keys": []
},
{
"id": 192,
"name": "作品名称",
"tags": [
"DailyRandom"
],
"type": null,
"arg_keys": []
},
{
"id": 31,
"name": "旅行目的地推荐",
"tags": [
"DailyRandom",
"Calendar",
"Weather"
],
"type": null,
"arg_keys": [
"城市"
]
},
{
"id": 32,
"name": "动物科普",
"tags": [
"DailyRandom",
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 33,
"name": "植物科普",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 42,
"name": "海洋生物科普",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 43,
"name": "昆虫科普",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 36,
"name": "佛经片段",
"tags": [
"DailyRandom",
"Calendar",
"feature",
"Lunar"
],
"type": null,
"arg_keys": []
},
{
"id": 44,
"name": "美食推荐",
"tags": [
"DailyRandom",
"Calendar",
"feature",
"Lunar"
],
"type": null,
"arg_keys": []
},
{
"id": 45,
"name": "博物馆文物推荐",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 46,
"name": "职场毒鸡汤",
"tags": [
"DailyRandom",
"Calendar",
"feature",
"Lunar"
],
"type": null,
"arg_keys": []
},
{
"id": 37,
"name": "每日学单词",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 11,
"name": "生活建议",
"tags": [
"Weather",
"Calendar",
"feature"
],
"type": null,
"arg_keys": [
"城市"
]
},
{
"id": 34,
"name": "历史上的今天",
"tags": [
"DailyRandom",
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 5,
"name": "冷知识",
"tags": [
"Weather",
"Calendar",
"DailyRandom"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 1,
"name": "心灵鸡汤",
"tags": [
"feature",
"Weather",
"Calendar",
"DailyRandom"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 189,
"name": "待办和天气",
"tags": [
"feature",
"DailyRandom",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市",
"待办事项"
]
},
{
"id": 24,
"name": "待办事项",
"tags": [
"feature",
"DailyRandom",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市",
"待办事项"
]
},
{
"id": 39,
"name": "情侣间的365件事",
"tags": [
"Calendar",
"feature",
"Lunar"
],
"type": null,
"arg_keys": []
},
{
"id": 25,
"name": "星座今日运势",
"tags": [
"feature",
"DailyRandom",
"Calendar"
],
"type": null,
"arg_keys": [
"生日"
]
},
{
"id": 26,
"name": "生肖今日运势",
"tags": [
"feature",
"DailyRandom",
"Lunar",
"Calendar"
],
"type": null,
"arg_keys": [
"生日"
]
},
{
"id": 16,
"name": "娱乐新闻",
"tags": [
"feature",
"DailyRandom",
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 21,
"name": "政治新闻",
"tags": [
"feature",
"DailyRandom",
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 12,
"name": "科技新闻",
"tags": [
"feature",
"DailyRandom",
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 15,
"name": "财经新闻",
"tags": [
"feature",
"DailyRandom",
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 48,
"name": "简约月历3",
"tags": [
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 47,
"name": "简约月历2",
"tags": [
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 41,
"name": "简约月历1",
"tags": [
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 23,
"name": "黄历",
"tags": [
"feature",
"DailyRandom",
"Lunar"
],
"type": null,
"arg_keys": []
},
{
"id": 19,
"name": "农历阳历",
"tags": [
"Lunar",
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 18,
"name": "简约日历3",
"tags": [
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 22,
"name": "简约日历2",
"tags": [
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 3,
"name": "简约日历1",
"tags": [
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 54,
"name": "极简日历5",
"tags": [
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 40,
"name": "极简日历4",
"tags": [
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 53,
"name": "KoKonna极简日历",
"tags": [
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 20,
"name": "极简日历3",
"tags": [
"Calendar",
"feature"
],
"type": null,
"arg_keys": []
},
{
"id": 4,
"name": "极简日历2",
"tags": [
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 14,
"name": "极简日历1",
"tags": [
"Calendar"
],
"type": null,
"arg_keys": []
},
{
"id": 228,
"name": "月历卡片",
"tags": [
"Weather",
"Calendar"
],
"type": null,
"arg_keys": [
"城市"
]
},
{
"id": 209,
"name": "卡片天气日历15",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 208,
"name": "卡片天气日历14",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 207,
"name": "卡片天气日历13",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 206,
"name": "卡片天气日历12",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 205,
"name": "卡片天气日历11",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 204,
"name": "卡片天气日历10",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 203,
"name": "卡片天气日历9",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 202,
"name": "卡片天气日历8",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 201,
"name": "卡片天气日历7",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 200,
"name": "卡片天气日历6",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 199,
"name": "卡片天气日历5",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 198,
"name": "卡片天气日历4",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 197,
"name": "卡片天气日历3",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 196,
"name": "卡片天气日历2",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 195,
"name": "卡片天气日历1",
"tags": [
"feature",
"Weather"
],
"type": null,
"arg_keys": [
"所在城市"
]
},
{
"id": 57,
"name": "自定义二维码",
"tags": [
"DailyRandom"
],
"type": null,
"arg_keys": [
"二维码内容",
"备注"
]
},
{
"id": 56,
"name": "画框分享二维码",
"tags": [
"DailyRandom"
],
"type": null,
"arg_keys": []
},
{
"id": 55,
"name": "画框状态",
"tags": [
"DailyRandom"
],
"type": null,
"arg_keys": []
},
{
"id": 52,
"name": "黑色艺术字贴纸2",
"tags": [
"DailyRandom",
"feature"
],
"type": null,
"arg_keys": [
"内容"
]
},
{
"id": 50,
"name": "黑色艺术字贴纸",
"tags": [
"DailyRandom",
"feature"
],
"type": null,
"arg_keys": [
"内容"
]
},
{
"id": 49,
"name": "白色艺术字贴纸",
"tags": [
"DailyRandom",
"feature"
],
"type": null,
"arg_keys": [
"内容"
]
},
{
"id": 17,
"name": "红色艺术字贴纸",
"tags": [
"DailyRandom",
"feature"
],
"type": null,
"arg_keys": [
"内容"
]
}
],
"notes": "Observed directory only; no user tokens, device IDs, photos or configuration values retained."
}
@@ -0,0 +1,100 @@
"""Read-only official widget RPC; separate from the device OpenAPI credential."""
import json
import time
from pathlib import Path
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.integrations.kokonna_official.rpc import OfficialRPC
from pigway_frame_flow.models import Widget, WidgetState
class WidgetReader:
def __init__(self, token="", robot_id=None, mock=False):
self.rpc = OfficialRPC(token)
self._robot_id = robot_id
self.mock = mock
self._cached = None
self._cached_at = 0.0
def read(self) -> WidgetState:
if (
self.rpc.authorized
and self._cached is not None
and time.monotonic() - self._cached_at < 60
):
return self._cached
snapshot = json.loads(
Path(__file__).with_name("widget_catalog.json").read_text()
)
catalog = [
Widget(**{k: row[k] for k in ("id", "name", "tags", "arg_keys")})
for row in snapshot["catalog"]
]
if self.mock:
return WidgetState(
source="mock",
configured=True,
catalog=catalog,
current=catalog[0],
current_verified=True,
)
if not self.rpc.authorized or self._robot_id is None:
return WidgetState(
source="snapshot",
configured=False,
catalog=catalog,
observed_at=snapshot["observed_at"],
status="AUTH_REQUIRED",
)
try:
data = self.rpc.widgets(self._robot_id)
rows = data["rows"]
catalog = [
Widget(
id=row["id"],
name=row["name"],
tags=row.get("tags") or [],
arg_keys=list((row.get("args") or {}).keys())
if isinstance(row.get("args"), dict)
else [],
)
for row in rows
]
selected = data.get("frameCoverRobot") or {}
cover = selected.get("frameCover") or {}
current_id = cover.get("id", selected.get("coverId"))
current = next((row for row in catalog if row.id == current_id), None)
robot = data.get("robot") or {}
no_widget = (
"frameCoverRobot" in data
and data["frameCoverRobot"] is None
and "frameCoverRobotId" in robot
and robot["frameCoverRobotId"] is None
)
result = WidgetState(
source="live",
configured=True,
catalog=catalog,
current=current,
current_verified=current is not None or no_widget,
status="OK"
if current
else "NO_WIDGET"
if no_widget
else "CURRENT_UNVERIFIED",
)
except FlowError:
raise
except (
ValueError,
KeyError,
TypeError,
AttributeError,
):
raise FlowError(
E.CLOUD_UNAVAILABLE, "Official widget query unavailable", 502
) from None
self._cached, self._cached_at = result, time.monotonic()
return result