diff --git a/docs/openapi.json b/docs/openapi.json index 5374a4b..f8d14ce 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5410,6 +5410,11 @@ "type": "boolean", "title": "Model Verified", "default": false + }, + "can_restore": { + "type": "boolean", + "title": "Can Restore", + "default": false } }, "type": "object", diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index a523681..849ea3f 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -243,7 +243,7 @@ def create_app(settings=None, start_worker=True): dependencies=[Depends(require("devices:read"))], ) def devices(): - return [d.device() for d in registry.drivers.values()] + return [device(id) for id in registry.drivers] @app.get( "/api/v1/devices/{device_id}", @@ -252,7 +252,16 @@ def create_app(settings=None, start_worker=True): dependencies=[Depends(require("devices:read"))], ) def device(device_id: str, refresh: bool = False): - return registry.get(device_id).device(refresh) + d = registry.get(device_id).device(refresh) + state = db.one("SELECT * FROM device_state WHERE device_id=?", (device_id,)) + possible = bool( + state + and ( + state["previous_image_id"] is not None + or state["base_image_id"] is not None + ) + ) and not service.blocked(device_id) + return d.model_copy(update={"can_restore": possible}) @app.get( "/api/v1/devices/{device_id}/images", @@ -432,6 +441,7 @@ def create_app(settings=None, start_worker=True): asset = assets.put( await run_in_threadpool(convert, data, fit, profile), settings.sanitize(name), + kind="cache", ) return asset @@ -445,7 +455,8 @@ def create_app(settings=None, start_worker=True): return [ assets.info(r["id"]) for r in db.all( - "SELECT id FROM assets ORDER BY created_at DESC LIMIT ?", (limit,) + "SELECT id FROM assets WHERE kind!='cache' ORDER BY created_at DESC LIMIT ?", + (limit,), ) ] diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 74567aa..777866f 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -237,6 +237,8 @@ class DisplayService: from pigway_frame_flow.models import Content asset = self.content_asset(TypeAdapter(Content).validate_python(payload)) + if action == "local-upload": + asset = self.assets.promote(asset["asset_id"]) self.update( id, "COMPLETED", dict(asset_id=asset["asset_id"], url=asset["url"]) ) @@ -330,6 +332,7 @@ class DisplayService: driver.display(image_id) result = {"image_id": image_id} if asset: + self.assets.promote(asset["asset_id"]) result["rendered_asset_id"] = asset["asset_id"] self.update(id, "SYNCING", result) driver.invalidate() diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 662809b..6ce3178 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -190,6 +190,7 @@ class Device(BaseModel): slideshow: dict[str, Any] = Field(default_factory=dict) storage_used_bytes: int | None = None model_verified: bool = False + can_restore: bool = False class GalleryImage(BaseModel): diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index 7c66eb8..1d22a95 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -79,6 +79,14 @@ class Assets: ) return self.info(asset_id) + def promote(self, asset_id): + row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,)) + if row and row["kind"] == "cache": + target = self.settings.data_dir / "images" / (asset_id + ".jpg") + self.path(asset_id).replace(target) + self.db.execute("UPDATE assets SET kind='images' WHERE id=?", (asset_id,)) + return self.info(asset_id) + def info(self, asset_id): row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,)) if not row: diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 659af08..08b8aac 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -37,9 +37,9 @@ function showModal(html){$('#modal-content').innerHTML=html;$('#modal').showModa async function confirmDialog(message,fn){showModal(`

${t('confirm')}

${esc(message)}

`);bind('modal-confirm',async()=>{await fn();$('#modal').close();});} function detail(label,value){return `
${t(label)}${esc(value)}
`;} const views={ - async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}

${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}

${t('refreshInterval')}

${t('testLabel')}
${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`
${t(l)}
${esc(v)}
`).join('')}

${t('device')}

${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}

${t('cloudRotation')}

${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}

${t('rotationNotice')}

${t('playlists')} →
`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};}, + async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}

${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}

${t('refreshInterval')}

${t('testLabel')}
${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`
${t(l)}
${esc(v)}
`).join('')}

${t('device')}

${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}

${t('cloudRotation')}

${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}

${t('rotationNotice')}

${t('playlists')} →
`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};}, async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets');if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${items.length?``:`
${t('noImages')}${t('emptyHint')}
`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`

${t('displayByName')}

`);bind('show-name-confirm',async()=>{await accepted('/api/v1/display',{device_id:state.device,content:{type:'image',image_name:$('#image-name').value}});$('#modal').close();});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];return accepted('/api/v1/display',{device_id:state.device,content:state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id}});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`

${esc(x.name)}

${esc(x.name)}`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+crypto.randomUUID()}});await waitOperation(result.operation_id);}await navigate();});});}, - async upload(){state.preview=null;$('#main').innerHTML=notice()+`
${t('uploadNotice')}

${t('upload')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=false;});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',()=>{toast(t('done'));state.tab='local';return navigate('gallery');});bind('display-upload',()=>accepted('/api/v1/display',{device_id:state.device,content:{type:'image',asset_id:state.preview.asset_id}}));}, + async upload(){state.preview=null;$('#main').innerHTML=notice()+`
${t('uploadNotice')}

${t('upload')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=false;});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>accepted('/api/v1/display',{device_id:state.device,content:{type:'image',asset_id:state.preview.asset_id}}));}, async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;state.templatePreview=null;$('#main').innerHTML=notice()+`
${t('templateHint')}
${state.templates.map((x,i)=>`
${['☀','▦','◒','▣','❝','☁'][i]}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('previewHint')}
`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=false;});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>accepted('/api/v1/display',{device_id:state.device,content:{type:'image',asset_id:state.templatePreview}}));}, async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`

Asia/Shanghai · ${t('automation')}

${buttons('new-job','newJob',true)}
${jobs.length?`
${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>``).join('')}${jobs.map((j,i)=>``).join('')}
${t(x)}
${esc(j.name)}
${esc(j.device_id)}
${t(j.schedule.type)}
${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}
${t(j.enabled?'enabled':'disabled')}${fmt(j.next_run)}${fmt(j.last_run)}${esc(j.last_result)}
`:`
${t('noTasks')}
`}
`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));}, async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`
${t('playlistNotice')}

${t('playlists')}

${buttons('new-playlist','newPlaylist',true)}
`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));}, diff --git a/scripts/ui-smoke.cjs b/scripts/ui-smoke.cjs index 6a5ad4b..3920e6f 100644 --- a/scripts/ui-smoke.cjs +++ b/scripts/ui-smoke.cjs @@ -12,6 +12,10 @@ const fs=require('fs');const path=require('path');const assert=require('assert') await page.screenshot({path:path.join(root,'docs/screenshots/dashboard-dark-en.png'),fullPage:true}); await page.locator('[data-page="gallery"]').click();await page.locator('.image-card').first().waitFor();await page.locator('.image-card .thumb').first().evaluate(img=>new Promise((resolve,reject)=>{if(img.complete&&img.naturalWidth)return resolve();img.onload=resolve;img.onerror=reject;}));checks++; await page.locator('#show-name').click();await page.locator('#image-name').fill('Original Art.jpg');await page.locator('#modal-cancel').click();checks++; + await page.locator('[data-page="upload"]').click();await page.locator('#file').setInputFiles({name:'browser-upload.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});checks++; + await page.locator('#make-preview').click();await page.waitForFunction(()=>!document.querySelector('#save-local').disabled);checks++; + await page.locator('#save-local').click();const uploaded=page.locator('.image-card').filter({hasText:'browser-upload.jpg'});await uploaded.waitFor();checks++; + await uploaded.locator('[data-delete]').click();await page.locator('#modal-confirm').click();await uploaded.waitFor({state:'hidden'});checks++; await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===6);checks++; await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++; await page.locator('#display-template').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++; diff --git a/tests/schema-contract.json b/tests/schema-contract.json index 2d48856..12125ed 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -89,6 +89,11 @@ "type": "boolean", "title": "Model Verified", "default": false + }, + "can_restore": { + "type": "boolean", + "title": "Can Restore", + "default": false } }, "type": "object", diff --git a/tests/test_api.py b/tests/test_api.py index b42cfa5..cf2a1f7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -345,3 +345,57 @@ def test_schema_contract(client): schemas = client.get("/openapi.json").json()["components"]["schemas"] expected = json.loads((Path(__file__).parent / "schema-contract.json").read_text()) assert {key: schemas[key] for key in expected} == expected + + +def test_preview_is_draft_until_saved(client, service): + out = BytesIO() + Image.new("RGB", (50, 50), "red").save(out, "PNG") + preview = client.post( + "/api/v1/preview", files={"file": ("draft.png", out.getvalue(), "image/png")} + ).json() + assert preview["name"] == "draft.jpg" + assert not any( + x["asset_id"] == preview["asset_id"] + for x in client.get("/api/v1/assets").json() + ) + saved = client.post( + "/api/v1/devices/living-room/upload", + json={"type": "image", "asset_id": preview["asset_id"]}, + ) + assert saved.status_code == 202 + service.process_one() + assert any( + x["asset_id"] == preview["asset_id"] + for x in client.get("/api/v1/assets").json() + ) + assert service.registry.get("living-room").client.uploads == 0 + + +def test_restore_availability(client, service): + assert client.get("/api/v1/devices/living-room").json()["can_restore"] is False + client.post( + "/api/v1/display", json={"content": {"type": "template", "template": "quote"}} + ) + service.process_one() + assert client.get("/api/v1/devices/living-room").json()["can_restore"] is True + + +def test_template_configuration_reload(client, service): + assert ( + client.put( + "/api/v1/templates/quote/config", json={"data": {"quote": "Saved words"}} + ).status_code + == 200 + ) + assert ( + client.get("/api/v1/templates/quote").json()["defaults"]["quote"] + == "Saved words" + ) + + +def test_docs_offline_resources(client): + html = client.get("/docs").text + assert "/ui/swagger-ui-bundle.js" in html + assert "cdn.jsdelivr" not in html + assert client.get("/ui/swagger-ui-bundle.js").status_code == 200 + assert client.get("/ui/swagger-ui.css").status_code == 200