diff --git a/docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md b/docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md new file mode 100644 index 0000000..72da400 --- /dev/null +++ b/docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md @@ -0,0 +1,60 @@ +# KoKonna official account capabilities + +Reviewed 2026-10-03. Source: official Android nightly 1.6.5 client, downloaded +from https://kokonna.art/pages/download-app. RPC method names and usage were +inspected without running or modifying the app. No vendor source is distributed. + +## Two independent access paths + +Frame Flow retains its existing device API Key integration. The documented +OpenAPI provides device status, upload, image listing/download/delete and display +selection by ID/name. It exposes rotation, timezone and automatic image-change +status as read fields, but does not document setters for them. + +Account login accesses the official RPC service. The current device key was +explicitly tested against device discovery and widget discovery: both rejected +it with `请重新登陆`. Account login, device discovery/detail and widget discovery +have been verified successfully with HTTP 200. Other methods below are found in +client code only: their availability, permissions and side effects have not been +live-tested. Missing OpenAPI documentation is not proof that no undocumented +API Key endpoint exists. + +## Additional frame-related capabilities found + +| Capability | RPC methods | Evidence and boundary | +| --- | --- | --- | +| Official widgets and parameters | getFrameCovers / setFrameCover | Catalog read verified (75 items); current selection can be empty. Selection/cancellation discovered, not tested. | +| Rotation configuration | setRotate | Discovered setter; OpenAPI only reads rotation. | +| Device name and timezone | changeName / app.setTimezone | Discovered setters; OpenAPI only reads these fields. | +| Slideshow mode and period | setFrameImageChangeType / setFrameImageChangeMinute | Discovered setters; actual rotation sequence must be derived from mode/filter/order, not invented. | +| Slideshow tag filters | setFrameImageChangeTags | Discovered filter configuration. | +| Tagged, paginated image library | getImages / addTag / removeTag / setImageTags | Discovered; original client passes `page`, `paegSize` (spelling as observed) and `tags`. Requires separate validation. | +| Image renaming | changeFrameImageName | Discovered; distinct from rename-device. | +| Official gallery collections | getFrameGallerys / getFrameGalleryImages / addFrameGalleryImage | Discovered browse/add flow; may depend on entitlement. | +| Artist content | getFrameArtists / addFrameArtist | Discovered; subscriptions/selection may consume points or alter display. | +| Startup/splash image | getFrameSplashs / setSplash | Discovered browse/set flow. | +| Remote refresh | refreshImage | Discovered write; do not infer display success from RPC acceptance. | +| Image sharing/import | getShareImages / shareImage / importImagesByTransferToken | Discovered; do not expose transfer tokens or share credentials. | +| Device sharing and pairing | enablePair / enableShareCode / refreshShareUUID; app.addShareRobot / removeShare | Discovered sensitive configuration; excluded from automatic tests. | +| Firmware information/upgrade | app.getFirmwares / setUpgradeFirmware | Discovered; not enabled or tested. | +| Wake/sleep interval | setFrameImageChangeInterval | Client updates `defaultSleepTime`; do not mistake this for slideshow minutes. | +| AI/voice content | chat / recognizeVoice / text2speech | Discovered; support may vary with hardware, service and account credits. | +| Feedback and likes | reportFrameImage / likeImage | Discovered; modifies remote account data. | + +RPC methods in this table use the `appFrame.` prefix unless explicitly `app.`. +Generic account methods also cover profile, password, device binding/unlinking, +credits/orders and custom assistant roles. Their presence in the shared client +is not evidence that all are supported by the 13.3-inch B101 frame. No binding, +reset, firmware, payment or subscription mutation was executed during research. + +## Frame Flow implementation boundary + +- API Key device/gallery/upload/display support remains unchanged. +- Official widget panel separately explains account authorization requirements. +- The existing WidgetReader uses a server-side official session secret; direct + account/password connection UI and automatic session renewal are not implemented. +- Live `NO_WIDGET` requires explicit null current-widget association and null + robot association ID; missing fields remain `CURRENT_UNVERIFIED`. +- Unconfigured access returns `AUTH_REQUIRED` and an explicitly dated snapshot. +- Widget selection and the other RPC writes above are not implemented. Account + login capability discovery is not permission to exercise those writes. diff --git a/pigway_frame_flow/drivers/kokonna/widgets.py b/pigway_frame_flow/drivers/kokonna/widgets.py index ecd41ff..96a1bd1 100644 --- a/pigway_frame_flow/drivers/kokonna/widgets.py +++ b/pigway_frame_flow/drivers/kokonna/widgets.py @@ -83,13 +83,24 @@ class WidgetReader: 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) + robot = data.get("robot") or {} + no_widget = ( + "frameCoverRobot" in data + and data["frameCoverRobot"] is None + and "frameCoverRobotId" in robot + and robot["frameCoverRobotId"] is None + ) result = WidgetState( source="live", configured=True, catalog=catalog, current=current, - current_verified=current is not None, - status="OK" if current else "CURRENT_UNVERIFIED", + current_verified=current is not None or no_widget, + status="OK" + if current + else "NO_WIDGET" + if no_widget + else "CURRENT_UNVERIFIED", ) except FlowError: raise diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index b838cad..9791a92 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -81,7 +81,8 @@ async function officialContentInfo(){ 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 currentLabel=!data.configured?(zh?'尚未授权官方账号,当前状态不可读取。':'Official account not authorized; current state is unavailable.'):data.status==='NO_WIDGET'?(zh?'未使用官方小组件':'No official widget selected'):data.current_verified&&data.current?((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实':'Current widget is unverified'); + info.innerHTML=`${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}

${esc(label)}

${esc(currentLabel)}

${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}

${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/scripts/ui-widgets.cjs b/scripts/ui-widgets.cjs new file mode 100644 index 0000000..1ec70c2 --- /dev/null +++ b/scripts/ui-widgets.cjs @@ -0,0 +1,16 @@ +/* Widget authorization and selection states; intercepted Mock responses only. */ +const assert=require('assert'),{chromium}=require('playwright'); +(async()=>{ + const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); + const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));let status='AUTH_REQUIRED',checks=0; + await page.route('**/api/v1/devices/*/widgets',route=>route.fulfill({json:{source:status==='AUTH_REQUIRED'?'snapshot':'live',configured:status!=='AUTH_REQUIRED',status,observed_at:'2026-10-02',catalog:[],current:null,current_verified:status==='NO_WIDGET',writable:false}})); + await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor(); + for(const lang of ['zh','en'])for(const value of ['AUTH_REQUIRED','NO_WIDGET','CURRENT_UNVERIFIED']){ + status=value;await page.evaluate(lang=>{state.lang=lang;return navigate('templates');},lang); + await page.locator('.widget-auth-notice').waitFor({state:'attached'}); + const text=await page.locator('.official-content-info').textContent(); + assert(text.includes(lang==='zh'?'现有 API Key 接入继续':'Your existing API Key continues')); + assert(text.includes(lang==='zh'?{AUTH_REQUIRED:'尚未授权官方账号',NO_WIDGET:'未使用官方小组件',CURRENT_UNVERIFIED:'当前小组件尚未核实'}[value]:{AUTH_REQUIRED:'Official account not authorized',NO_WIDGET:'No official widget selected',CURRENT_UNVERIFIED:'Current widget is unverified'}[value]));checks++; + } + assert(errors.length===0);await browser.close();console.log(`Widget UI: ${checks} states passed; 0 page errors`); +})().catch(e=>{console.error(e.message);process.exit(1)}); diff --git a/tests/test_widgets.py b/tests/test_widgets.py index c04f79b..e32e288 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -67,3 +67,23 @@ def test_widget_api_mock_and_auth(client): assert len(response.json()["catalog"]) == 75 client.headers.clear() assert client.get("/api/v1/devices/living-room/widgets").status_code == 401 + + +@pytest.mark.parametrize( + "robot,selected,status", + [ + ({"frameCoverRobotId": None}, None, "NO_WIDGET"), + ({}, None, "CURRENT_UNVERIFIED"), + ({"frameCoverRobotId": 123}, None, "CURRENT_UNVERIFIED"), + ], +) +def test_no_selection_is_distinct_from_unknown(robot, selected, status): + response = Mock(status_code=200) + response.json.return_value = { + "result": {"rows": [], "robot": robot, "frameCoverRobot": selected} + } + with patch("requests.post", return_value=response): + result = WidgetReader("secret", 123).read() + assert result.status == status + assert result.current is None + assert result.current_verified == (status == "NO_WIDGET")