Isolate official account RPC capabilities from API key device driver
This commit is contained in:
@@ -58,3 +58,13 @@ reset, firmware, payment or subscription mutation was executed during research.
|
|||||||
- Unconfigured access returns `AUTH_REQUIRED` and an explicitly dated snapshot.
|
- Unconfigured access returns `AUTH_REQUIRED` and an explicitly dated snapshot.
|
||||||
- Widget selection and the other RPC writes above are not implemented. Account
|
- Widget selection and the other RPC writes above are not implemented. Account
|
||||||
login capability discovery is not permission to exercise those writes.
|
login capability discovery is not permission to exercise those writes.
|
||||||
|
|
||||||
|
## Module extraction
|
||||||
|
|
||||||
|
Official capabilities now live in `pigway_frame_flow/integrations/kokonna_official/`.
|
||||||
|
The account RPC client, login/session handling, operation inventory and widget
|
||||||
|
normalization/catalog are isolated there. API Key drivers no longer own a widget
|
||||||
|
reader. DeviceRegistry composes the optional integration separately per device;
|
||||||
|
the existing public widget route and old Python import remain compatible.
|
||||||
|
Only login and the three verified reads are enabled. The module has no generic
|
||||||
|
public RPC execution endpoint, and unenabled methods cannot reach the network.
|
||||||
|
|||||||
@@ -762,7 +762,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
dependencies=[Depends(require("devices:read"))],
|
dependencies=[Depends(require("devices:read"))],
|
||||||
)
|
)
|
||||||
def official_widgets(device_id: str):
|
def official_widgets(device_id: str):
|
||||||
return registry.get(device_id).widgets()
|
return registry.official_widgets(device_id)
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/api/v1/devices/{device_id}/current-content",
|
"/api/v1/devices/{device_id}/current-content",
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from pigway_frame_flow.drivers.base import FrameDeviceDriver
|
|||||||
from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
|
from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
|
||||||
from pigway_frame_flow.drivers.kokonna.driver import KokonnaDriver
|
from pigway_frame_flow.drivers.kokonna.driver import KokonnaDriver
|
||||||
from pigway_frame_flow.drivers.kokonna.mock import MockClient
|
from pigway_frame_flow.drivers.kokonna.mock import MockClient
|
||||||
from pigway_frame_flow.drivers.kokonna.widgets import WidgetReader
|
|
||||||
from pigway_frame_flow.errors import ErrorCode as E
|
from pigway_frame_flow.errors import ErrorCode as E
|
||||||
from pigway_frame_flow.errors import FlowError
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader
|
||||||
from pigway_frame_flow.settings import secret
|
from pigway_frame_flow.settings import secret
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ class DeviceRegistry:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.drivers: dict[str, FrameDeviceDriver] = {}
|
self.drivers: dict[str, FrameDeviceDriver] = {}
|
||||||
|
self.official: dict[str, WidgetReader] = {}
|
||||||
self.keys = []
|
self.keys = []
|
||||||
for id, entry in devices.items():
|
for id, entry in devices.items():
|
||||||
if not entry.get("enabled", True):
|
if not entry.get("enabled", True):
|
||||||
@@ -55,9 +56,14 @@ class DeviceRegistry:
|
|||||||
widgets = WidgetReader(
|
widgets = WidgetReader(
|
||||||
widget_token, entry.get("widget_robot_id"), settings.mock
|
widget_token, entry.get("widget_robot_id"), settings.mock
|
||||||
)
|
)
|
||||||
self.drivers[id] = KokonnaDriver(client, id, settings.sanitize, widgets)
|
self.official[id] = widgets
|
||||||
|
self.drivers[id] = KokonnaDriver(client, id, settings.sanitize)
|
||||||
|
|
||||||
def get(self, id: str) -> FrameDeviceDriver:
|
def get(self, id: str) -> FrameDeviceDriver:
|
||||||
if id not in self.drivers:
|
if id not in self.drivers:
|
||||||
raise FlowError(E.DEVICE_NOT_FOUND, "Device not found", 404)
|
raise FlowError(E.DEVICE_NOT_FOUND, "Device not found", 404)
|
||||||
return self.drivers[id]
|
return self.drivers[id]
|
||||||
|
|
||||||
|
def official_widgets(self, id: str):
|
||||||
|
self.get(id)
|
||||||
|
return self.official[id].read()
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from pigway_frame_flow.models import Device, GalleryImage
|
|||||||
|
|
||||||
|
|
||||||
class FrameDeviceDriver(Protocol):
|
class FrameDeviceDriver(Protocol):
|
||||||
def widgets(self): ...
|
|
||||||
def invalidate(self) -> None: ...
|
def invalidate(self) -> None: ...
|
||||||
def device(self, refresh: bool = False) -> Device: ...
|
def device(self, refresh: bool = False) -> Device: ...
|
||||||
def get_current_image(self) -> int | None: ...
|
def get_current_image(self) -> int | None: ...
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ from pigway_frame_flow.models import (
|
|||||||
|
|
||||||
|
|
||||||
class KokonnaDriver:
|
class KokonnaDriver:
|
||||||
def __init__(self, client, device_id, sanitize=lambda v: v, widgets=None):
|
def __init__(self, client, device_id, sanitize=lambda v: v):
|
||||||
self.client, self.device_id, self.sanitize = client, device_id, sanitize
|
self.client, self.device_id, self.sanitize = client, device_id, sanitize
|
||||||
self.widget_reader = widgets
|
|
||||||
self.cached: Device | None = None
|
self.cached: Device | None = None
|
||||||
self.cached_at = 0.0
|
self.cached_at = 0.0
|
||||||
|
|
||||||
@@ -63,9 +62,6 @@ class KokonnaDriver:
|
|||||||
self.cached_at = time.monotonic()
|
self.cached_at = time.monotonic()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def widgets(self):
|
|
||||||
return self.widget_reader.read()
|
|
||||||
|
|
||||||
def get_current_image(self):
|
def get_current_image(self):
|
||||||
return self.device().current_content.id
|
return self.device().current_content.id
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +1,5 @@
|
|||||||
"""Read-only official widget RPC; separate from the device OpenAPI credential."""
|
"""Compatibility import; official capabilities live outside device drivers."""
|
||||||
|
|
||||||
import json
|
from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import requests
|
__all__ = ["WidgetReader"]
|
||||||
|
|
||||||
from pigway_frame_flow.errors import ErrorCode as E
|
|
||||||
from pigway_frame_flow.errors import FlowError
|
|
||||||
from pigway_frame_flow.models import Widget, WidgetState
|
|
||||||
|
|
||||||
|
|
||||||
class WidgetReader:
|
|
||||||
def __init__(self, token="", robot_id=None, mock=False):
|
|
||||||
self._token = token
|
|
||||||
self._robot_id = robot_id
|
|
||||||
self.mock = mock
|
|
||||||
self._cached = None
|
|
||||||
self._cached_at = 0.0
|
|
||||||
|
|
||||||
def read(self) -> WidgetState:
|
|
||||||
if 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._token or self._robot_id is None:
|
|
||||||
return WidgetState(
|
|
||||||
source="snapshot",
|
|
||||||
configured=False,
|
|
||||||
catalog=catalog,
|
|
||||||
observed_at=snapshot["observed_at"],
|
|
||||||
status="AUTH_REQUIRED",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
"https://robot.galaxyguide.cn/api/rpc",
|
|
||||||
headers={"Authorization": f"Bearer {self._token}"},
|
|
||||||
json={
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"method": "appFrame.getFrameCovers",
|
|
||||||
"params": {"robotId": self._robot_id},
|
|
||||||
"id": int(time.time() * 1000),
|
|
||||||
},
|
|
||||||
timeout=15,
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
if response.status_code in (401, 403):
|
|
||||||
raise FlowError(E.UNAUTHORIZED, "Official widget session expired", 401)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response.json()
|
|
||||||
if payload.get("error"):
|
|
||||||
raise FlowError(
|
|
||||||
E.CLOUD_UNAVAILABLE, "Official widget query rejected", 502
|
|
||||||
)
|
|
||||||
data = payload["result"]
|
|
||||||
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 (
|
|
||||||
requests.RequestException,
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
+1
-1
@@ -16,7 +16,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["pigway_frame_flow*", "vendor*"]
|
include = ["pigway_frame_flow*", "vendor*"]
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
pigway_frame_flow = ["templates/*.json", "web/*", "drivers/kokonna/*.json"]
|
pigway_frame_flow = ["templates/*.json", "web/*", "integrations/kokonna_official/*.json"]
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E4", "E7", "E9", "F", "I", "B"]
|
select = ["E4", "E7", "E9", "F", "I", "B"]
|
||||||
ignore = ["B008"] # FastAPI dependency defaults are declarative framework markers.
|
ignore = ["B008"] # FastAPI dependency defaults are declarative framework markers.
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pigway_frame_flow.errors import ErrorCode, FlowError
|
||||||
|
from pigway_frame_flow.integrations.kokonna_official.capabilities import capabilities
|
||||||
|
from pigway_frame_flow.integrations.kokonna_official.rpc import OfficialRPC
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_session_not_password_used_for_reads():
|
||||||
|
login = Mock(status_code=200)
|
||||||
|
login.json.return_value = {"result": {"token": "session-secret"}}
|
||||||
|
devices = Mock(status_code=200)
|
||||||
|
devices.json.return_value = {"result": {"data": []}}
|
||||||
|
rpc = OfficialRPC()
|
||||||
|
with patch("requests.post", side_effect=[login, devices]) as post:
|
||||||
|
rpc.login("account", "password-secret")
|
||||||
|
assert rpc.devices() == {"data": []}
|
||||||
|
assert post.call_args.kwargs["headers"] == {
|
||||||
|
"Authorization": "Bearer session-secret"
|
||||||
|
}
|
||||||
|
assert "password-secret" not in str(post.call_args.kwargs)
|
||||||
|
rpc.disconnect()
|
||||||
|
assert not rpc.authorized
|
||||||
|
with patch("requests.post") as post, pytest.raises(FlowError):
|
||||||
|
rpc.devices()
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method",
|
||||||
|
[
|
||||||
|
"appFrame.setFrameCover",
|
||||||
|
"appFrame.reset",
|
||||||
|
"appFrame.uploadImage",
|
||||||
|
"arbitrary.method",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_all_unenabled_operations_blocked_without_network(method):
|
||||||
|
with patch("requests.post") as post, pytest.raises(FlowError) as error:
|
||||||
|
OfficialRPC("session")._call(method, {})
|
||||||
|
assert error.value.code == ErrorCode.UNSUPPORTED_CAPABILITY
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_http_500_relogin_is_auth_error_not_cloud_failure():
|
||||||
|
response = Mock(status_code=500)
|
||||||
|
response.json.return_value = {"error": {"code": -1, "message": "请重新登陆"}}
|
||||||
|
with (
|
||||||
|
patch("requests.post", return_value=response),
|
||||||
|
pytest.raises(FlowError) as error,
|
||||||
|
):
|
||||||
|
OfficialRPC("secret").devices()
|
||||||
|
assert error.value.code == ErrorCode.UNAUTHORIZED
|
||||||
|
assert "secret" not in str(error.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inventory_does_not_claim_unverified_writes_implemented():
|
||||||
|
entries = {entry.method: entry for entry in capabilities()}
|
||||||
|
assert entries["appFrame.setRotate"].implemented is False
|
||||||
|
assert entries["appFrame.getFrameCovers"].evidence == "live_verified"
|
||||||
|
assert all(
|
||||||
|
not entry.implemented
|
||||||
|
for entry in entries.values()
|
||||||
|
if entry.evidence == "apk_discovered"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_key_driver_has_no_official_dependency():
|
||||||
|
for name in ["client.py", "driver.py"]:
|
||||||
|
source = (Path("pigway_frame_flow/drivers/kokonna") / name).read_text()
|
||||||
|
assert "kokonna_official" not in source
|
||||||
|
assert "WidgetReader" not in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_disconnect_does_not_return_authenticated_cache():
|
||||||
|
from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader
|
||||||
|
|
||||||
|
response = Mock(status_code=200)
|
||||||
|
response.json.return_value = {
|
||||||
|
"result": {
|
||||||
|
"rows": [],
|
||||||
|
"robot": {"frameCoverRobotId": None},
|
||||||
|
"frameCoverRobot": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader = WidgetReader("session", 123)
|
||||||
|
with patch("requests.post", return_value=response):
|
||||||
|
assert reader.read().status == "NO_WIDGET"
|
||||||
|
reader.rpc.disconnect()
|
||||||
|
with patch("requests.post") as post:
|
||||||
|
assert reader.read().status == "AUTH_REQUIRED"
|
||||||
|
post.assert_not_called()
|
||||||
Reference in New Issue
Block a user