diff --git a/docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md b/docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md index 9fc3e39..7f20a36 100644 --- a/docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md +++ b/docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md @@ -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. diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index b216b60..5fbf78f 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -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, diff --git a/pigway_frame_flow/core/registry.py b/pigway_frame_flow/core/registry.py index b37e817..5f7a9c8 100644 --- a/pigway_frame_flow/core/registry.py +++ b/pigway_frame_flow/core/registry.py @@ -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: diff --git a/pigway_frame_flow/drivers/base.py b/pigway_frame_flow/drivers/base.py index 4c5ccc8..30a7659 100644 --- a/pigway_frame_flow/drivers/base.py +++ b/pigway_frame_flow/drivers/base.py @@ -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: ... diff --git a/pigway_frame_flow/drivers/kokonna/driver.py b/pigway_frame_flow/drivers/kokonna/driver.py index 1c67482..bff0c64 100644 --- a/pigway_frame_flow/drivers/kokonna/driver.py +++ b/pigway_frame_flow/drivers/kokonna/driver.py @@ -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 diff --git a/pigway_frame_flow/drivers/kokonna/widget_catalog.json b/pigway_frame_flow/drivers/kokonna/widget_catalog.json new file mode 100644 index 0000000..8545a25 --- /dev/null +++ b/pigway_frame_flow/drivers/kokonna/widget_catalog.json @@ -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." +} diff --git a/pigway_frame_flow/drivers/kokonna/widgets.py b/pigway_frame_flow/drivers/kokonna/widgets.py new file mode 100644 index 0000000..ecd41ff --- /dev/null +++ b/pigway_frame_flow/drivers/kokonna/widgets.py @@ -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 diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 3b0157f..6ed9ef6 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -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 diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index e4d39b7..b838cad 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -73,8 +73,16 @@ async function poetryControls(){ bind('daily-poetry-job',()=>{showModal(`

${t('dailyPoetryJob')}

${t('poetryScheduleHint')}

`);$('#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=`${t('officialContentTitle')}

${t('officialContentDescription')}

${t('officialSubscriptionUnavailable')}

${t('officialProductDocs')}${t('officialAPIDocs')}
`; + const zh=state.lang==='zh'; + info.innerHTML=`${zh?'官方小组件':'Official widgets'}

${zh?'正在读取…':'Loading…'}

`; $('.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=`${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}

${esc(label)}

${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.')}

${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}

`; + 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=>`
${esc(x.name)}

${esc(x.tags.join(' · '))}

${esc(x.arg_keys.join(' · '))}
`).join('');}; + info.querySelector('input').oninput=render;render(); + }catch(e){info.querySelector('p').textContent=e.message;} } diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index 317a893..a179ff2 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -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} diff --git a/pyproject.toml b/pyproject.toml index 194060d..43a325d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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. diff --git a/tests/contract.json b/tests/contract.json index 603babd..e064d5a 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -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" + ] } } diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 0000000..c04f79b --- /dev/null +++ b/tests/test_widgets.py @@ -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