From e1b001a431beba3d360eefdb032d95449c975f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Wed, 14 Oct 2026 13:10:33 +0800 Subject: [PATCH] Reconcile lost upload responses without replaying frame writes --- docs/REAL_DEVICE_TEST.md | 16 ++ pigway_frame_flow/display/service.py | 24 ++- .../display/upload_reconciliation.py | 157 ++++++++++++++++++ pigway_frame_flow/errors.py | 1 + pigway_frame_flow/web/i18n.js | 4 +- tests/test_upload_reconciliation.py | 113 +++++++++++++ 6 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 pigway_frame_flow/display/upload_reconciliation.py create mode 100644 tests/test_upload_reconciliation.py diff --git a/docs/REAL_DEVICE_TEST.md b/docs/REAL_DEVICE_TEST.md index 650c42d..54a7d7a 100644 --- a/docs/REAL_DEVICE_TEST.md +++ b/docs/REAL_DEVICE_TEST.md @@ -25,3 +25,19 @@ The API cannot set coverId. Restoration guarantees a requested image switch when confirmed, not arbitrary cover metadata restoration. Physical screen refresh has no documented pixel telemetry; visual owner observation supplements cloud synced. + +### Upload response reconciliation + +Before an upload, the service persists its asset ID, exact SHA256/size, filename, +existing gallery IDs and timestamp. When the response is lost, read-only queries +look for one new image in the upload time window; only an exact downloaded-byte +match identifies it. Ambiguous, missing or altered content remains locked for +manual review. No upload or switch is replayed by reconciliation. + +A proven upload completes the display only when the device reports that exact +image and `synced=true`. If a normal display remains on its previous synced +image beyond the sync timeout plus five minutes, two observations at least one +minute apart record `DISPLAY_NOT_CONFIRMED` and unblock future scheduled work. +Temporary overrides retain their restore behavior and are not automatically +closed as failed. Legacy operations without durable upload evidence require +manual acknowledgement; absence from the gallery alone never proves failure. diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 872e902..2337492 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -7,6 +7,7 @@ from uuid import uuid4 from pigway_frame_flow.display.cloud_library import CloudLibrary from pigway_frame_flow.display.retention import Retention +from pigway_frame_flow.display.upload_reconciliation import UploadReconciliation from pigway_frame_flow.drivers.base import DriverRegistry from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError @@ -49,6 +50,7 @@ class DisplayService: self.lock = threading.RLock() self.retention = Retention(self) self.cloud_library = CloudLibrary(self) + self.upload_reconciliation = UploadReconciliation(self) self.expiration_checked: dict[str, float] = {} self.unknown_checked: dict[str, float] = {} # A killed write cannot be replayed safely; pre-write work can resume. @@ -169,8 +171,9 @@ class DisplayService: if result: row = self.db.one("SELECT result FROM operations WHERE id=?", (id,)) previous = json.loads(row["result"]) if row and row["result"] else {} - if "photo_attempts" in previous: - result = {**result, "photo_attempts": previous["photo_attempts"]} + for key in ("photo_attempts", "upload_intent"): + if key in previous: + result = {**result, key: previous[key]} self.db.execute( "UPDATE operations SET status=?,updated_at=?,result=COALESCE(?,result),error=? WHERE id=?", ( @@ -627,6 +630,8 @@ class DisplayService: raise FlowError( E.CONFLICT, "Temporary override requires an existing image", 409 ) + if asset: + self.upload_reconciliation.prepare(id, driver, asset, previous) self._start_write(id) if asset: self.update(id, "UPLOADING") @@ -690,22 +695,31 @@ class DisplayService: ) def sync(self): + with self.lock: + self._sync() + + def _sync(self): # Late acknowledgements only resolve a known display target. Never replay # writes or infer success from gallery presence alone. for op in self.db.all( "SELECT * FROM operations WHERE status='UNKNOWN' AND action='display' AND write_started=1" ): expected = json.loads(op["result"] or "{}").get("image_id") - if not expected: + intent = json.loads(op["result"] or "{}").get("upload_intent") + if not expected and not intent: continue checked = time.monotonic() if checked - self.unknown_checked.get(op["id"], float("-inf")) < 60: continue self.unknown_checked[op["id"]] = checked try: - device = self.registry.get(op["device_id"]).device(refresh=True) + driver = self.registry.get(op["device_id"]) + device = driver.device(refresh=True) + if intent: + expected = self.upload_reconciliation.check(op, driver, device) if ( - device.current_content.id == expected + expected + and device.current_content.id == expected and device.current_content.synced ): self.update(op["id"], "COMPLETED") diff --git a/pigway_frame_flow/display/upload_reconciliation.py b/pigway_frame_flow/display/upload_reconciliation.py new file mode 100644 index 0000000..2a770f5 --- /dev/null +++ b/pigway_frame_flow/display/upload_reconciliation.py @@ -0,0 +1,157 @@ +"""Resolve missing upload responses using read-only, exact-content evidence.""" + +import hashlib +import json +from datetime import UTC, datetime, timedelta + +from pigway_frame_flow.errors import ErrorCode +from pigway_frame_flow.storage.database import encode, now_iso + + +class UploadReconciliation: + def __init__(self, service): + self.service = service + + def prepare(self, operation_id, driver, asset, previous): + path = self.service.assets.path(asset["asset_id"]) + self.service.update( + operation_id, + "RENDERING", + { + "rendered_asset_id": asset["asset_id"], + "upload_intent": { + "name": asset["name"], + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "size": path.stat().st_size, + "before_ids": [image.image_id for image in driver.images()], + "previous_image_id": previous, + "started_at": now_iso(), + }, + }, + ) + + def check(self, op, driver, device): + service = self.service + result = json.loads(op["result"] or "{}") + intent = result.get("upload_intent") + if not intent: + return None + expected = result.get("image_id") + if not expected: + start = datetime.fromisoformat(intent["started_at"]) + candidates = [] + for image in driver.images(): + if ( + image.image_id in intent["before_ids"] + or image.name != intent["name"] + ): + continue + if image.size_bytes is not None and image.size_bytes != intent["size"]: + continue + if not image.created_at: + continue + try: + created = datetime.fromisoformat(image.created_at) + age = (created - start).total_seconds() + except (ValueError, TypeError): + continue + if 0 <= age <= service.settings.sync_timeout + 300: + candidates.append(image) + # Ambiguous matches never release a write lock. + if len(candidates) != 1: + return None + expected = candidates[0].image_id + data = driver.download(expected) + if ( + len(data) != intent["size"] + or hashlib.sha256(data).hexdigest() != intent["sha256"] + ): + return None + result["image_id"] = expected + result["upload_confirmed"] = True + service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}")) + service.assets.promote(result["rendered_asset_id"]) + source = service.db.one( + "SELECT payload FROM asset_sources WHERE asset_id=?", + (result["rendered_asset_id"],), + ) + service.db.execute( + "INSERT OR REPLACE INTO image_sources VALUES(?,?,?,?,?)", + ( + op["device_id"], + expected, + result["rendered_asset_id"], + op["id"], + source["payload"] if source else encode({"type": "image"}), + ), + ) + if not service.db.one( + "SELECT image_id FROM managed_cloud_uploads WHERE operation_id=?", + (op["id"],), + ): + service.cloud_library.register( + op["device_id"], expected, result["rendered_asset_id"], op["id"] + ) + if device.current_content.id == expected and device.current_content.synced: + options = json.loads(op["payload"]).get("options", {}) + service.db.execute( + "UPDATE device_state SET managed_image_id=? WHERE device_id=?", + (expected, op["device_id"]), + ) + if options.get("restore_previous"): + expires = datetime.fromisoformat(intent["started_at"]) + timedelta( + seconds=options["duration"] + ) + service.db.execute( + "INSERT OR IGNORE INTO overrides VALUES(?,?,?,?,?,?)", + ( + op["id"], + op["device_id"], + expected, + options["priority"], + expires.isoformat(), + intent["started_at"], + ), + ) + return expected + elapsed = ( + datetime.now(UTC) - datetime.fromisoformat(intent["started_at"]) + ).total_seconds() + if ( + result.get("upload_confirmed") + and not json.loads(op["payload"]).get("options", {}).get("restore_previous") + and elapsed > service.settings.sync_timeout + 300 + and device.current_content.id == intent["previous_image_id"] + and device.current_content.synced + ): + # Upload is proven; display is not. Two separated stable observations + # close the failed display, without replaying either vendor write. + observed = result.get("unchanged_observed_at") + if not observed: + result["unchanged_observed_at"] = now_iso() + service.update( + op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}") + ) + elif ( + datetime.now(UTC) - datetime.fromisoformat(observed) + ).total_seconds() >= 60: + service.update( + op["id"], + "FAILED", + result, + { + "code": ErrorCode.DISPLAY_NOT_CONFIRMED, + "message": "Upload confirmed; frame remained on previous image", + "request_id": op["request_id"], + "details": {}, + }, + ) + service.db.history(op, "DISPLAY_NOT_CONFIRMED") + service.db.event( + "operation.failed", + {"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED}, + ) + elif result.get("unchanged_observed_at"): + result.pop("unchanged_observed_at") + service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}")) + return expected diff --git a/pigway_frame_flow/errors.py b/pigway_frame_flow/errors.py index 21024a5..ff10431 100644 --- a/pigway_frame_flow/errors.py +++ b/pigway_frame_flow/errors.py @@ -11,6 +11,7 @@ class ErrorCode(StrEnum): UNSUPPORTED_CAPABILITY = "UNSUPPORTED_CAPABILITY" RENDER_FAILED = "RENDER_FAILED" UPLOAD_FAILED = "UPLOAD_FAILED" + DISPLAY_NOT_CONFIRMED = "DISPLAY_NOT_CONFIRMED" WRITE_RESULT_UNKNOWN = "WRITE_RESULT_UNKNOWN" RATE_LIMITED = "RATE_LIMITED" INTERNAL_ERROR = "INTERNAL_ERROR" diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 076ac52..4a521ee 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -1,6 +1,6 @@ window.MESSAGES={ -zh:{OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'云端图库',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'仅保存到本地图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 1200 × 1600',uploadNotice:'KoKonna 云端上传会立即切换画框。仅保存图库时使用本地暂存,不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'只读',writable:'允许写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'这是当前显示图片。删除后画框将切换到其他图片。是否继续?',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'设备云端异常,请稍后刷新',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'}, -en:{OFFICIAL_AUTH_REQUIRED:'Official account authorization expired; reconnect',INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'Cloud gallery',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · 1200 × 1600',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'This is the current image. Deleting it will make the device switch to another image. Continue?',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Device cloud error. Try refreshing later.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'} +zh:{DISPLAY_NOT_CONFIRMED:'图片已上传,但画框未确认显示;后续任务已恢复',OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'云端图库',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'仅保存到本地图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 1200 × 1600',uploadNotice:'KoKonna 云端上传会立即切换画框。仅保存图库时使用本地暂存,不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'只读',writable:'允许写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'这是当前显示图片。删除后画框将切换到其他图片。是否继续?',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'设备云端异常,请稍后刷新',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'}, +en:{DISPLAY_NOT_CONFIRMED:'Image uploaded but display was not confirmed; future jobs unblocked',OFFICIAL_AUTH_REQUIRED:'Official account authorization expired; reconnect',INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'Cloud gallery',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · 1200 × 1600',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'This is the current image. Deleting it will make the device switch to another image. Continue?',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Device cloud error. Try refreshing later.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'} }; Object.assign(window.MESSAGES.zh,{username:'账号',password:'密码',connect:'登录 Frame Flow',UNAUTHORIZED:'账号或密码错误,或登录已过期',tokenHint:'使用账号和密码登录。勾选记住登录后可保持 30 天;共享设备请退出登录。',logout:'退出登录',noToken:'请登录管理画框',formEditor:'表单编辑',advanced:'高级 JSON',resetTemplate:'恢复默认模板',resetConfirm:'清除已保存的模板配置,恢复内置默认值?',eventsHelp:'每行一个事项',photo:'家庭照片',noPhoto:'使用照片占位区域',changePassword:'修改密码',currentPassword:'当前密码',newPassword:'新密码(至少 8 位)',passwordChanged:'密码已修改,请重新登录',saveTemplate:'保存模板设置'}); diff --git a/tests/test_upload_reconciliation.py b/tests/test_upload_reconciliation.py new file mode 100644 index 0000000..f4404e7 --- /dev/null +++ b/tests/test_upload_reconciliation.py @@ -0,0 +1,113 @@ +import json +from datetime import UTC, datetime, timedelta + +import pytest +from test_display import submit + +from pigway_frame_flow.errors import ErrorCode, FlowError + + +def lose_response(service, monkeypatch, change_display=True): + driver = service.registry.get("living-room") + upload = driver.upload + + def uncertain(path, name): + upload(path, name) + if not change_display: + driver.client.display(1) + raise FlowError( + ErrorCode.WRITE_RESULT_UNKNOWN, "Cloud write outcome is unknown", 502 + ) + + monkeypatch.setattr(driver, "upload", uncertain) + operation = submit(service) + service.process_one() + assert service.operation(operation)["status"] == "UNKNOWN" + return operation, driver + + +def test_lost_upload_response_resolves_by_exact_bytes_without_reupload( + service, monkeypatch +): + operation, driver = lose_response(service, monkeypatch) + service.sync() + assert service.operation(operation)["status"] == "COMPLETED" + assert service.operation(operation)["result"]["image_id"] == 2 + assert driver.client.uploads == 1 + assert not service.blocked("living-room") + + +def test_same_name_wrong_bytes_does_not_unlock(service, monkeypatch): + operation, driver = lose_response(service, monkeypatch) + monkeypatch.setattr(driver, "download", lambda _: b"unrelated") + service.sync() + assert service.operation(operation)["status"] == "UNKNOWN" + assert service.blocked("living-room") + assert driver.client.uploads == 1 + + +def test_ambiguous_gallery_does_not_download_or_unlock(service, monkeypatch): + operation, driver = lose_response(service, monkeypatch) + driver.client.state["images"].append(dict(driver.client.state["images"][-1], id=3)) + monkeypatch.setattr(driver, "download", lambda _: pytest.fail("Ambiguous download")) + service.sync() + assert service.operation(operation)["status"] == "UNKNOWN" + + +def test_confirmed_upload_not_displayed_closes_failed_display_after_stable_checks( + service, monkeypatch +): + operation, driver = lose_response(service, monkeypatch, change_display=False) + service.sync() + result = service.operation(operation)["result"] + assert result["upload_confirmed"] + start = datetime.fromisoformat(result["upload_intent"]["started_at"]) + import pigway_frame_flow.display.upload_reconciliation as module + + class Later(datetime): + @classmethod + def now(cls, tz=None): + return start + timedelta(seconds=service.settings.sync_timeout + 600) + + monkeypatch.setattr(module, "datetime", Later) + service.unknown_checked.clear() + service.sync() + assert service.operation(operation)["status"] == "UNKNOWN" + result = service.operation(operation)["result"] + result["unchanged_observed_at"] = ( + datetime.now(UTC) - timedelta(seconds=61) + ).isoformat() + service.db.execute( + "UPDATE operations SET result=? WHERE id=?", (json.dumps(result), operation) + ) + service.unknown_checked.clear() + service.sync() + assert service.operation(operation)["status"] == "FAILED" + assert service.operation(operation)["error"]["code"] == "DISPLAY_NOT_CONFIRMED" + assert not service.blocked("living-room") + assert driver.client.uploads == 1 + + +def test_legacy_unknown_without_intent_remains_locked(service): + operation = submit(service) + service._start_write(operation) + service.update(operation, "UNKNOWN") + service.sync() + assert service.blocked("living-room") + + +def test_recovered_upload_preserves_temporary_restore(service, monkeypatch): + driver = service.registry.get("living-room") + upload = driver.upload + + def uncertain(path, name): + upload(path, name) + raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Response lost", 502) + + monkeypatch.setattr(driver, "upload", uncertain) + operation = submit(service, duration=60, restore=True) + service.process_one() + service.sync() + assert service.operation(operation)["status"] == "COMPLETED" + assert service.top("living-room")["id"] == operation + assert service.state("living-room")["managed_image_id"] == 2