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
+11
View File
@@ -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,
+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.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:
+1
View File
@@ -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: ...
+5 -1
View File
@@ -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
+18
View File
@@ -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
+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');});});
}
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;}
}
+2
View File
@@ -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}