Add isolated read-only official widget integration and searchable catalog
This commit is contained in:
@@ -62,3 +62,35 @@ The selected-widget metadata extractor returned null for cover ID/name during th
|
||||
Cleanup verified: PAC URL `(null)`, disabled; zero observer CA certificate entries in System keychain; observer terminated; private CA files removed; user session only existed in process memory and was discarded. Original HTTP/HTTPS/SOCKS proxy remains 127.0.0.1:7890. No official subscription/upload/display/delete writes were sent. Other NAS services and VPN application configuration were untouched.
|
||||
|
||||
Integration is now technically supported by live read evidence, not blocked on interface discovery. Remaining work is authenticated current-selection verification and an explicit maintainable official-session login/renewal strategy. Do not silently persist the captured WeChat session or commit it as configuration. Official RPC is undocumented and may change independently of OpenAPI.
|
||||
|
||||
## Read-only integration implemented
|
||||
|
||||
`GET /api/v1/devices/{device_id}/widgets` returns a typed catalog, source
|
||||
(`live`, `snapshot`, `mock`), configuration status and verified current selection.
|
||||
The template page provides searchable official widgets, separately from local
|
||||
Frame Flow templates. Missing credentials show the 2026-10-02 observed catalog
|
||||
of 75 entries; this is explicitly a snapshot, never a live subscription claim.
|
||||
|
||||
For an authorized official session, configure a device in `config/devices.yml`:
|
||||
|
||||
```yaml
|
||||
devices:
|
||||
living-room:
|
||||
driver: kokonna
|
||||
api_key_env: KOKONNA_API_KEY
|
||||
widget_token_env: KOKONNA_WIDGET_TOKEN
|
||||
widget_robot_id: 123 # replace with the official internal robot ID
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Supply `KOKONNA_WIDGET_TOKEN_FILE` pointing to a protected mounted secret file,
|
||||
or inject `KOKONNA_WIDGET_TOKEN`. This credential is a separate official user
|
||||
session, not the OpenAPI device key or Frame Flow login password. No observed
|
||||
WeChat session has been persisted. Sessions may expire; there is no automatic
|
||||
WeChat login or refresh implementation yet. Successful reads are cached for
|
||||
60 seconds. Raw robot data, token-bearing URLs and argument values are omitted.
|
||||
|
||||
Only `appFrame.getFrameCovers` is callable by this integration. Widget selection,
|
||||
subscription mutation and cancellation are not implemented or executed. A null
|
||||
or unmatched current selection returns `CURRENT_UNVERIFIED`, not “no subscription”.
|
||||
This private RPC contract is unofficial and may change independently of OpenAPI.
|
||||
|
||||
@@ -753,6 +753,17 @@ def create_app(settings=None, start_worker=True):
|
||||
weather_provider=weather.config().provider,
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/devices/{device_id}/widgets",
|
||||
response_model=__import__(
|
||||
"pigway_frame_flow.models", fromlist=["WidgetState"]
|
||||
).WidgetState,
|
||||
tags=["Devices"],
|
||||
dependencies=[Depends(require("devices:read"))],
|
||||
)
|
||||
def official_widgets(device_id: str):
|
||||
return registry.get(device_id).widgets()
|
||||
|
||||
@app.get(
|
||||
"/api/v1/devices/{device_id}/current-content",
|
||||
response_model=CurrentDisplay,
|
||||
|
||||
@@ -4,8 +4,10 @@ from pigway_frame_flow.drivers.base import FrameDeviceDriver
|
||||
from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
|
||||
from pigway_frame_flow.drivers.kokonna.driver import KokonnaDriver
|
||||
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 FlowError
|
||||
from pigway_frame_flow.settings import secret
|
||||
|
||||
|
||||
class DeviceRegistry:
|
||||
@@ -38,8 +40,6 @@ class DeviceRegistry:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
client = MockClient(root)
|
||||
else:
|
||||
from pigway_frame_flow.settings import secret
|
||||
|
||||
key = (
|
||||
settings.api_key
|
||||
if entry.get("api_key_env", "KOKONNA_API_KEY") == "KOKONNA_API_KEY"
|
||||
@@ -48,7 +48,14 @@ class DeviceRegistry:
|
||||
self.keys.append(key)
|
||||
settings.additional_secrets.append(key)
|
||||
client = KokonnaClient(key, settings.write_enabled)
|
||||
self.drivers[id] = KokonnaDriver(client, id, settings.sanitize)
|
||||
widget_token = secret(
|
||||
str(entry.get("widget_token_env", "KOKONNA_WIDGET_TOKEN"))
|
||||
)
|
||||
settings.additional_secrets.append(widget_token)
|
||||
widgets = WidgetReader(
|
||||
widget_token, entry.get("widget_robot_id"), settings.mock
|
||||
)
|
||||
self.drivers[id] = KokonnaDriver(client, id, settings.sanitize, widgets)
|
||||
|
||||
def get(self, id: str) -> FrameDeviceDriver:
|
||||
if id not in self.drivers:
|
||||
|
||||
@@ -4,6 +4,7 @@ from pigway_frame_flow.models import Device, GalleryImage
|
||||
|
||||
|
||||
class FrameDeviceDriver(Protocol):
|
||||
def widgets(self): ...
|
||||
def invalidate(self) -> None: ...
|
||||
def device(self, refresh: bool = False) -> Device: ...
|
||||
def get_current_image(self) -> int | None: ...
|
||||
|
||||
@@ -8,8 +8,9 @@ from pigway_frame_flow.models import (
|
||||
|
||||
|
||||
class KokonnaDriver:
|
||||
def __init__(self, client, device_id, sanitize=lambda v: v):
|
||||
def __init__(self, client, device_id, sanitize=lambda v: v, widgets=None):
|
||||
self.client, self.device_id, self.sanitize = client, device_id, sanitize
|
||||
self.widget_reader = widgets
|
||||
self.cached: Device | None = None
|
||||
self.cached_at = 0.0
|
||||
|
||||
@@ -62,6 +63,9 @@ class KokonnaDriver:
|
||||
self.cached_at = time.monotonic()
|
||||
return result
|
||||
|
||||
def widgets(self):
|
||||
return self.widget_reader.read()
|
||||
|
||||
def get_current_image(self):
|
||||
return self.device().current_content.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,107 @@
|
||||
"""Read-only official widget RPC; separate from the device OpenAPI credential."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
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)
|
||||
result = WidgetState(
|
||||
source="live",
|
||||
configured=True,
|
||||
catalog=catalog,
|
||||
current=current,
|
||||
current_verified=current is not None,
|
||||
status="OK" if current 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
|
||||
@@ -390,3 +390,21 @@ class ImageURLConfig(StrictModel):
|
||||
raise ValueError("Use hostnames only, without URLs or wildcards")
|
||||
self.allowed_hosts = sorted(set(h.lower() for h in self.allowed_hosts))
|
||||
return self
|
||||
|
||||
|
||||
class Widget(StrictModel):
|
||||
id: int
|
||||
name: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
arg_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WidgetState(StrictModel):
|
||||
source: Literal["live", "snapshot", "mock"]
|
||||
configured: bool
|
||||
status: str = "OK"
|
||||
observed_at: str | None = None
|
||||
catalog: list[Widget]
|
||||
current: Widget | None = None
|
||||
current_verified: bool = False
|
||||
writable: Literal[False] = False
|
||||
|
||||
@@ -73,8 +73,16 @@ async function poetryControls(){
|
||||
bind('daily-poetry-job',()=>{showModal(`<h2>${t('dailyPoetryJob')}</h2><form id="poetry-job-form"><div class="field"><label for="poetry-job-time">${t('time')}</label><input id="poetry-job-time" type="time" required value="07:00"></div><label class="check-row"><input id="poetry-job-enabled" type="checkbox">${t('enabled')}</label><p class="hint">${t('poetryScheduleHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#poetry-job-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:t('dailyPoetryJob'),enabled:$('#poetry-job-enabled').checked,schedule:{type:'daily',time:$('#poetry-job-time').value},source:{type:'template',template:'daily-poetry',data:{},profile:$('#template-profile').value},display_duration:0,restore_previous:false,priority:50}});$('#modal').close();await navigate('automation');});});
|
||||
}
|
||||
|
||||
function officialContentInfo(){
|
||||
async function officialContentInfo(){
|
||||
const info=document.createElement('details');info.className='official-content-info';
|
||||
info.innerHTML=`<summary>${t('officialContentTitle')}</summary><p>${t('officialContentDescription')}</p><ul>${['officialCalendarWeather','officialWordAlmanacTravel','officialNews','officialCreative'].map(k=>`<li>${t(k)}</li>`).join('')}</ul><p class="notice">${t('officialSubscriptionUnavailable')}</p><div class="actions"><a href="https://kokonna.art/products/kokonna-ai-e-ink-frame-7" target="_blank" rel="noopener noreferrer">${t('officialProductDocs')}</a><a href="https://kokonna.art/pages/openapi" target="_blank" rel="noopener noreferrer">${t('officialAPIDocs')}</a></div>`;
|
||||
const zh=state.lang==='zh';
|
||||
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'}</summary><p>${zh?'正在读取…':'Loading…'}</p>`;
|
||||
$('.template-grid').before(info);
|
||||
try {
|
||||
const data=await api('/api/v1/devices/'+state.device+'/widgets');
|
||||
const label=data.source==='live'?(zh?'实时目录':'Live catalog'):data.source==='mock'?'Mock':(zh?'历史目录 · '+data.observed_at:'Catalog snapshot · '+data.observed_at);
|
||||
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}</summary><p>${esc(label)}</p><p class="notice">${data.current_verified?esc((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实。官方微信会话与设备 API Key 是不同凭据。':'Current widget is unverified. The official session differs from the device API Key.')}</p><p>${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`;
|
||||
const render=()=>{const query=info.querySelector('input').value.toLowerCase();info.querySelector('.widget-list').innerHTML=data.catalog.filter(x=>(x.name+' '+x.tags.join(' ')).toLowerCase().includes(query)).map(x=>`<article class="card"><strong>${esc(x.name)}</strong><p>${esc(x.tags.join(' · '))}</p><small>${esc(x.arg_keys.join(' · '))}</small></article>`).join('');};
|
||||
info.querySelector('input').oninput=render;render();
|
||||
}catch(e){info.querySelector('p').textContent=e.message;}
|
||||
}
|
||||
|
||||
@@ -38,3 +38,5 @@ details[open]>summary{margin-bottom:16px}
|
||||
.official-content-info .notice{margin:20px 0}
|
||||
.official-content-info .actions a{display:inline-block;padding:8px 0}
|
||||
.tabs{gap:10px}
|
||||
|
||||
.widget-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-top:20px}.official-content-info>p{margin:16px 0}.widget-list .card{padding:20px}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ build-backend = "setuptools.build_meta"
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["pigway_frame_flow*", "vendor*"]
|
||||
[tool.setuptools.package-data]
|
||||
pigway_frame_flow = ["templates/*.json", "web/*"]
|
||||
pigway_frame_flow = ["templates/*.json", "web/*", "drivers/kokonna/*.json"]
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I", "B"]
|
||||
ignore = ["B008"] # FastAPI dependency defaults are declarative framework markers.
|
||||
|
||||
@@ -766,5 +766,21 @@
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/devices/{device_id}/widgets": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pigway_frame_flow.drivers.kokonna.widgets import WidgetReader
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
|
||||
def test_snapshot_does_not_contact_official_service():
|
||||
with patch("requests.post") as post:
|
||||
result = WidgetReader().read()
|
||||
assert result.source == "snapshot"
|
||||
assert len(result.catalog) == 75
|
||||
assert not result.configured and not result.current_verified and not result.writable
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_live_current_and_cache():
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {
|
||||
"result": {
|
||||
"rows": [
|
||||
{"id": 29, "name": "古诗词", "tags": [], "args": {"城市": "private"}}
|
||||
],
|
||||
"frameCoverRobot": {
|
||||
"frameCover": {"id": 29},
|
||||
"args": {"private": "secret"},
|
||||
},
|
||||
}
|
||||
}
|
||||
with patch("requests.post", return_value=response) as post:
|
||||
reader = WidgetReader("official-secret", 123)
|
||||
result = reader.read()
|
||||
assert reader.read() is result
|
||||
assert result.current_verified and result.current.id == 29
|
||||
assert "private" not in result.model_dump_json()
|
||||
assert "official-secret" not in result.model_dump_json()
|
||||
assert post.call_count == 1
|
||||
assert post.call_args.kwargs["json"]["method"] == "appFrame.getFrameCovers"
|
||||
assert post.call_args.kwargs["allow_redirects"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload", [{"error": {"message": "official-secret"}}, {"result": {}}]
|
||||
)
|
||||
def test_vendor_errors_are_not_exposed(payload):
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = payload
|
||||
with (
|
||||
patch("requests.post", return_value=response),
|
||||
pytest.raises(FlowError) as error,
|
||||
):
|
||||
WidgetReader("official-secret", 123).read()
|
||||
assert "official-secret" not in str(error.value)
|
||||
|
||||
|
||||
def test_missing_robot_id_never_uses_session():
|
||||
with patch("requests.post") as post:
|
||||
assert not WidgetReader("secret").read().configured
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_widget_api_mock_and_auth(client):
|
||||
response = client.get("/api/v1/devices/living-room/widgets")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["source"] == "mock"
|
||||
assert len(response.json()["catalog"]) == 75
|
||||
client.headers.clear()
|
||||
assert client.get("/api/v1/devices/living-room/widgets").status_code == 401
|
||||
Reference in New Issue
Block a user