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.
|
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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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