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)}
${buttons('modal-cancel','cancel')}${buttons('modal-confirm','confirm',true)}
`);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')}
${[['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])=>`
`).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')}
${[['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])=>`
`).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()+`${t('cloud')} ${t('local')}
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`${t('uploadNotice')}
`:''}${items.length?`${items.map((x,i)=>`
${esc(x.name)} ${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))} ${x.current?`${t('current')}
`:''}${t('display')} ${t('original')} ${t('delete')}
`).join('')}
`:`${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')} ${t('name')}
${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}
`);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)} ${buttons('modal-cancel','cancel')}
`);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')} ${buttons('make-preview','preview',true)}${t('previewHint')}
${t('preview')} ${t('previewHint')}
${t('staging')} ${t('display')}
`;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=' ';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')} ${buttons('make-preview','preview',true)}${t('previewHint')}
${t('preview')} ${t('previewHint')}
${t('staging')} ${t('display')}
`;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=' ';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')} ${t('profile')} ${t('color')} ${t('grayscale')}
${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}
${t('preview')} ${t('previewHint')}
${t('display')} `;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=' ';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=>`${t(x)} `).join('')} ${jobs.map((j,i)=>`${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('edit')} ${t(j.enabled?'disabled':'enabled')} ${t('remove')}
`).join('')}
`:`
${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)}${playlists.map((p,i)=>`
${t(p.enabled?'enabled':'disabled')} ${esc(p.name)} ${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}
${esc(p.image_ids.join(' → '))}
${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'} ${t('edit')} ${t('remove')}
`).join('')||`
${t('noTasks')}
`}
`;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