Add isolated read-only official widget integration and searchable catalog

This commit is contained in:
Pigway
2026-10-02 16:20:54 +08:00
parent bfef1ad28d
commit 59fbdf7c1a
13 changed files with 1144 additions and 7 deletions
@@ -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. 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. 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.
+11
View File
@@ -753,6 +753,17 @@ def create_app(settings=None, start_worker=True):
weather_provider=weather.config().provider, 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( @app.get(
"/api/v1/devices/{device_id}/current-content", "/api/v1/devices/{device_id}/current-content",
response_model=CurrentDisplay, response_model=CurrentDisplay,
+10 -3
View File
@@ -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.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.settings import secret
class DeviceRegistry: class DeviceRegistry:
@@ -38,8 +40,6 @@ class DeviceRegistry:
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
client = MockClient(root) client = MockClient(root)
else: else:
from pigway_frame_flow.settings import secret
key = ( key = (
settings.api_key settings.api_key
if entry.get("api_key_env", "KOKONNA_API_KEY") == "KOKONNA_API_KEY" if entry.get("api_key_env", "KOKONNA_API_KEY") == "KOKONNA_API_KEY"
@@ -48,7 +48,14 @@ class DeviceRegistry:
self.keys.append(key) self.keys.append(key)
settings.additional_secrets.append(key) settings.additional_secrets.append(key)
client = KokonnaClient(key, settings.write_enabled) 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: def get(self, id: str) -> FrameDeviceDriver:
if id not in self.drivers: if id not in self.drivers:
+1
View File
@@ -4,6 +4,7 @@ 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: ...
+5 -1
View File
@@ -8,8 +8,9 @@ from pigway_frame_flow.models import (
class KokonnaDriver: 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.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
@@ -62,6 +63,9 @@ 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
@@ -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
+18
View File
@@ -390,3 +390,21 @@ class ImageURLConfig(StrictModel):
raise ValueError("Use hostnames only, without URLs or wildcards") raise ValueError("Use hostnames only, without URLs or wildcards")
self.allowed_hosts = sorted(set(h.lower() for h in self.allowed_hosts)) self.allowed_hosts = sorted(set(h.lower() for h in self.allowed_hosts))
return self 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
+10 -2
View File
@@ -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');});}); 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'; 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); $('.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;}
} }
+2
View File
@@ -38,3 +38,5 @@ details[open]>summary{margin-bottom:16px}
.official-content-info .notice{margin:20px 0} .official-content-info .notice{margin:20px 0}
.official-content-info .actions a{display:inline-block;padding:8px 0} .official-content-info .actions a{display:inline-block;padding:8px 0}
.tabs{gap:10px} .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
View File
@@ -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/*"] pigway_frame_flow = ["templates/*.json", "web/*", "drivers/kokonna/*.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.
+16
View File
@@ -766,5 +766,21 @@
"502", "502",
"503" "503"
] ]
},
"/api/v1/devices/{device_id}/widgets": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
} }
} }
+69
View File
@@ -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