From 8085c714224d231d85127f9ed021551a676f6d7b Mon Sep 17 00:00:00 2001 From: AeBoPi <109503402+AeBoPi@users.noreply.github.com> Date: Tue, 26 May 2026 21:14:06 +0800 Subject: [PATCH 1/8] feat: update mobile app mode settings --- backend/api/routes/mobile.py | 4 +- backend/core/native_dither.py | 5 +- inksight-mobile/app/device/[mac].tsx | 42 ++++- inksight-mobile/app/device/[mac]/config.tsx | 41 +++++ .../app/device/[mac]/mode-settings.tsx | 160 +++++++++++++++++- inksight-mobile/features/device/api.ts | 22 +++ inksight-mobile/messages/en.json | 13 ++ inksight-mobile/messages/zh.json | 13 ++ inksight-mobile/package-lock.json | 22 +++ inksight-mobile/package.json | 1 + 10 files changed, 315 insertions(+), 8 deletions(-) diff --git a/backend/api/routes/mobile.py b/backend/api/routes/mobile.py index 2175dcd7..bfe0e0c0 100644 --- a/backend/api/routes/mobile.py +++ b/backend/api/routes/mobile.py @@ -289,6 +289,8 @@ async def get_widget_data( content = _fallback_content(selected_mode, city) updated_at = datetime.now().isoformat() + # Strip _prefetched_* binary blobs from content before JSON serialization + clean_content = {k: v for k, v in content.items() if not k.startswith("_prefetched_")} info = get_registry().get_mode_info(selected_mode) return { "mac": mac.upper(), @@ -297,5 +299,5 @@ async def get_widget_data( "icon": info.icon if info else "star", "updated_at": updated_at, "preview_url": _preview_url(selected_mode, mac=mac.upper(), city=(config or {}).get("city")), - "content": content, + "content": clean_content, } diff --git a/backend/core/native_dither.py b/backend/core/native_dither.py index 3e6c94be..f1ff9ade 100644 --- a/backend/core/native_dither.py +++ b/backend/core/native_dither.py @@ -8,7 +8,10 @@ from .config import EINK_4COLOR_PALETTE -_LIB_PATH = Path(__file__).resolve().parent / "native" / "libeink_dither.so" +import platform + +_EXT = ".dll" if platform.system() == "Windows" else ".so" +_LIB_PATH = Path(__file__).resolve().parent / "native" / f"libeink_dither{_EXT}" _LIB: ctypes.CDLL | None = None _BUILD_HINT = "run `python3 backend/scripts/build_native_dither.py` from the repository root" diff --git a/inksight-mobile/app/device/[mac].tsx b/inksight-mobile/app/device/[mac].tsx index 1cb0dbce..a45a8828 100644 --- a/inksight-mobile/app/device/[mac].tsx +++ b/inksight-mobile/app/device/[mac].tsx @@ -86,6 +86,8 @@ export default function DeviceDetailScreen() { const [lastWidgetRefreshAt, setLastWidgetRefreshAt] = useState(0); const [previewImageUri, setPreviewImageUri] = useState(null); const [previewImageBytes, setPreviewImageBytes] = useState(null); + const [previewColors, setPreviewColors] = useState(2); + const [previewSize, setPreviewSize] = useState('400x300'); const stateQuery = useQuery({ queryKey: ['device-state', mac, token], @@ -112,12 +114,15 @@ export default function DeviceDetailScreen() { if (configQuery.data?.modes?.[0]) { setSelectedWidgetMode(configQuery.data.modes[0]); } + if (configQuery.data?.screenSize) { + setPreviewSize(configQuery.data.screenSize); + } }, [configQuery.data]); useEffect(() => { setPreviewImageUri(null); setPreviewImageBytes(null); - }, [selectedWidgetMode]); + }, [selectedWidgetMode, previewColors, previewSize]); const state = stateQuery.data; const config = configQuery.data; @@ -175,8 +180,13 @@ export default function DeviceDetailScreen() { if (data?.preview_url) { try { const rawUrl = buildApiUrl(data.preview_url); + const [pw, ph] = previewSize.split('x').map(Number); + const params = new URLSearchParams(); + if (previewColors > 2) params.set('colors', String(previewColors)); + if (pw && ph) { params.set('w', String(pw)); params.set('h', String(ph)); } + const extra = params.toString(); const sep = rawUrl.includes('?') ? '&' : '?'; - const url = `${rawUrl}${sep}no_cache=1`; + const url = `${rawUrl}${sep}no_cache=1${extra ? '&' + extra : ''}`; const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (resp.ok) { const bytes = await resp.arrayBuffer(); @@ -238,7 +248,7 @@ export default function DeviceDetailScreen() { return t('device.widgetEmpty'); } - const HARDCODED_CONFIGURABLE = ['CALENDAR', 'TIMETABLE']; + const HARDCODED_CONFIGURABLE = ['CALENDAR', 'TIMETABLE', 'MY_ADAPTIVE']; function isModeConfigurable(modeId: string): boolean { if (HARDCODED_CONFIGURABLE.includes(modeId.toUpperCase())) return true; @@ -294,6 +304,26 @@ export default function DeviceDetailScreen() { /> ))} + + {[{ label: '4.2"', s: '400x300' }, { label: '2.9"', s: '296x128' }, { label: '5.83"', s: '648x480' }].map((opt) => ( + setPreviewSize(opt.s)} + /> + ))} + + + {[{ label: t('device.colorBW'), v: 2 }, { label: t('device.colorBWR'), v: 3 }, { label: t('device.colorBWRY'), v: 4 }].map((opt) => ( + setPreviewColors(opt.v)} + /> + ))} + {widgetStatusText()} @@ -367,4 +397,10 @@ const styles = StyleSheet.create({ width: '100%', aspectRatio: 400 / 300, }, + segmentRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginTop: 12, + }, }); diff --git a/inksight-mobile/app/device/[mac]/config.tsx b/inksight-mobile/app/device/[mac]/config.tsx index 78231a5b..5dec31a3 100644 --- a/inksight-mobile/app/device/[mac]/config.tsx +++ b/inksight-mobile/app/device/[mac]/config.tsx @@ -51,6 +51,8 @@ export default function DeviceConfigScreen() { const [city, setCity] = useState('Hangzhou'); const [refreshInterval, setRefreshInterval] = useState('60'); const [selectedModes, setSelectedModes] = useState(['DAILY']); + const [screenSize, setScreenSize] = useState('400x300'); + const [screenColors, setScreenColors] = useState(2); useEffect(() => { if (!configQuery.data) return; @@ -58,6 +60,7 @@ export default function DeviceConfigScreen() { setCity(configQuery.data.city || 'Hangzhou'); setRefreshInterval(String(configQuery.data.refreshInterval || 60)); setSelectedModes(configQuery.data.modes || ['DAILY']); + if (configQuery.data.screenSize) setScreenSize(configQuery.data.screenSize); }, [configQuery.data]); const saveMutation = useMutation({ @@ -74,6 +77,7 @@ export default function DeviceConfigScreen() { llmProvider: configQuery.data?.llmProvider || 'deepseek', llmModel: configQuery.data?.llmModel || 'deepseek-chat', modeOverrides: configQuery.data?.modeOverrides, + screenSize, }), onSuccess: () => { if (mac) { @@ -143,6 +147,38 @@ export default function DeviceConfigScreen() { )} + + {t('device.configScreenSize')} + + {[{ label: '4.2"', size: '400x300' }, { label: '2.9"', size: '296x128' }, { label: '5.83"', size: '648x480' }].map((opt, i) => { + const active = screenSize === opt.size; + return ( + setScreenSize(opt.size)} + /> + ); + })} + + + {t('device.configColors')} + + {[{ label: t('device.colorBW'), value: 2 }, { label: t('device.colorBWR'), value: 3 }, { label: t('device.colorBWRY'), value: 4 }].map((opt) => { + const active = screenColors === opt.value; + return ( + setScreenColors(opt.value)} + /> + ); + })} + + + >({}); + // --- MY_ADAPTIVE --- + const [adaptiveImageUrls, setAdaptiveImageUrls] = useState([]); + const [adaptiveUploading, setAdaptiveUploading] = useState(false); + useEffect(() => { if (!configQuery.data) return; const ov = configQuery.data.modeOverrides?.[modeId] ?? {}; @@ -103,6 +108,15 @@ export default function ModeSettingsScreen() { setPeriods(p); setCourseGrid(c); } + } else if (modeId === 'MY_ADAPTIVE') { + const urls = ov.image_urls; + if (Array.isArray(urls) && urls.length > 0) { + setAdaptiveImageUrls(urls.filter((u: unknown) => typeof u === 'string' && (u as string).trim()) as string[]); + } else if (typeof ov.image_url === 'string' && (ov.image_url as string).trim()) { + setAdaptiveImageUrls([ov.image_url as string]); + } else { + setAdaptiveImageUrls([]); + } } else { const sv: Record = {}; for (const [k, v] of Object.entries(ov)) { @@ -138,6 +152,9 @@ export default function ModeSettingsScreen() { if (v.trim()) c[k] = v.trim(); } base.courses = c; + } else if (modeId === 'MY_ADAPTIVE') { + base.image_urls = [...adaptiveImageUrls]; + base.image_url = adaptiveImageUrls[0] || ''; } else { for (const [k, v] of Object.entries(schemaValues)) { if (v.trim()) base[k] = v.trim(); @@ -177,6 +194,39 @@ export default function ModeSettingsScreen() { onError: (err) => Alert.alert(t('device.modeSettingsSaveFailed'), err instanceof Error ? err.message : ''), }); + const handlePickAdaptiveImage = async () => { + if (adaptiveImageUrls.length >= 6) { + Alert.alert(t('ms.adaptiveMaxReached')); + return; + } + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert(t('ms.adaptiveSelectPhoto')); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: false, + quality: 0.9, + }); + if (result.canceled || !result.assets || result.assets.length === 0) return; + const asset = result.assets[0]; + setAdaptiveUploading(true); + try { + const url = await uploadImage( + asset.uri, + asset.mimeType || 'image/jpeg', + asset.fileName || 'photo.jpg', + ); + setAdaptiveImageUrls((prev) => (prev.length >= 6 ? prev : [...prev, url])); + } catch (err) { + const msg = err instanceof Error ? err.message : t('ms.adaptiveUploadFailed'); + Alert.alert(t('ms.adaptiveUploadFailed'), msg); + } finally { + setAdaptiveUploading(false); + } + }; + const modeLabel = modeDisplayName(modeId, locale, modeId); // --- schema-based fields for generic modes --- @@ -451,7 +501,48 @@ export default function ModeSettingsScreen() { ); } - const hasCustomEditor = ['WEATHER', 'MEMO', 'COUNTDOWN', 'CALENDAR', 'TIMETABLE'].includes(modeId); + function renderAdaptive() { + return ( + + {t('ms.adaptiveTitle')} + {t('ms.adaptiveHint')} + + {adaptiveImageUrls.map((url, i) => ( + + + setAdaptiveImageUrls((prev) => prev.filter((_, idx) => idx !== i))} + > + + + + {i + 1} + + + ))} + {adaptiveImageUrls.length < 6 && ( + + {adaptiveUploading ? ( + {t('ms.adaptiveUploading')} + ) : ( + <> + + + {t('ms.adaptiveAdd')} + + )} + + )} + + + ); + } + + const hasCustomEditor = ['WEATHER', 'MEMO', 'COUNTDOWN', 'CALENDAR', 'TIMETABLE', 'MY_ADAPTIVE'].includes(modeId); return ( @@ -463,6 +554,7 @@ export default function ModeSettingsScreen() { {modeId === 'COUNTDOWN' && renderCountdown()} {modeId === 'CALENDAR' && renderCalendar()} {modeId === 'TIMETABLE' && renderTimetable()} + {modeId === 'MY_ADAPTIVE' && renderAdaptive()} {!hasCustomEditor && (schema.length > 0 ? renderGenericSchema() : ( {t('device.modeSettingsNoSchema')} ))} @@ -582,4 +674,66 @@ const styles = StyleSheet.create({ fontSize: 14, color: theme.colors.ink, }, + adaptiveGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + adaptiveItem: { + width: '30%', + aspectRatio: 4 / 3, + borderRadius: theme.radius.md, + overflow: 'hidden', + backgroundColor: theme.colors.surface, + }, + adaptiveImg: { + width: '100%', + height: '100%', + resizeMode: 'cover', + }, + adaptiveRemoveBtn: { + position: 'absolute', + top: 4, + right: 4, + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, + adaptiveRemoveText: { + color: '#fff', + fontSize: 11, + lineHeight: 14, + }, + adaptiveIndex: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + backgroundColor: 'rgba(0,0,0,0.35)', + paddingVertical: 2, + alignItems: 'center', + }, + adaptiveIndexText: { + color: '#fff', + fontSize: 10, + }, + adaptiveAddBtn: { + width: '30%', + aspectRatio: 4 / 3, + borderRadius: theme.radius.md, + borderWidth: 1.5, + borderColor: theme.colors.border, + borderStyle: 'dashed', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'transparent', + }, + adaptiveAddIcon: { + fontSize: 22, + color: theme.colors.secondary, + lineHeight: 26, + }, }); diff --git a/inksight-mobile/features/device/api.ts b/inksight-mobile/features/device/api.ts index faf50a81..9cee11e1 100644 --- a/inksight-mobile/features/device/api.ts +++ b/inksight-mobile/features/device/api.ts @@ -39,6 +39,7 @@ export type DeviceConfig = { llmProvider?: string; llmModel?: string; modeOverrides?: Record>; + screenSize?: string; }; export type DeviceMember = { @@ -277,6 +278,27 @@ export async function pushPreviewImageToDevice(mac: string, token: string, previ return response.json() as Promise<{ ok: boolean; message: string }>; } +export async function uploadImage(uri: string, mimeType: string, fileName: string): Promise { + const fd = new FormData(); + fd.append("file", { + uri, + name: fileName || "photo.jpg", + type: mimeType || "image/jpeg", + } as any); + const resp = await apiFetch("/uploads", { method: "POST", body: fd, contentType: null }); + if (!resp.ok) { + let msg = `upload failed: ${resp.status}`; + try { + const payload = await resp.json(); + msg = payload.message || payload.error || msg; + } catch {} + throw new Error(msg); + } + const data = (await resp.json()) as { url?: string }; + if (!data.url) throw new Error("upload failed: missing url"); + return data.url; +} + export function getDeviceShareImageUrl(mac: string, width = 800, height = 450) { const params = new URLSearchParams({ w: String(width), diff --git a/inksight-mobile/messages/en.json b/inksight-mobile/messages/en.json index ef373805..e25fc053 100644 --- a/inksight-mobile/messages/en.json +++ b/inksight-mobile/messages/en.json @@ -248,6 +248,11 @@ "device.configRefreshInterval": "Refresh interval (min)", "device.configModesBuiltin": "Built-in modes", "device.configModesCustomEmpty": "No custom modes yet. Add them from Browse or Create.", + "device.configScreenSize": "Screen Size", + "device.configColors": "Screen Colors", + "device.colorBW": "B&W", + "device.colorBWR": "BWR", + "device.colorBWRY": "BWRY", "device.widgetTitle": "Modes", "device.widgetFallback": "Sign in to view the desktop widget preview data.", "device.widgetLoginPrompt": "Sign in to view widget preview data.", @@ -344,6 +349,14 @@ "ms.day5": "Sat", "ms.day6": "Sun", "ms.remove": "Remove", + "ms.adaptiveTitle": "Photo Frame Images", + "ms.adaptiveHint": "Upload up to 6 images. The device cycles to the next image on each refresh.", + "ms.adaptiveAdd": "Add Image", + "ms.adaptiveRemove": "Remove", + "ms.adaptiveUploading": "Uploading...", + "ms.adaptiveUploadFailed": "Upload failed", + "ms.adaptiveMaxReached": "Maximum 6 images reached", + "ms.adaptiveSelectPhoto": "Select Photo", "firmware.title": "Firmware", "firmware.subtitle": "Check the latest release and package information.", "firmware.selectVariant": "Select firmware variant", diff --git a/inksight-mobile/messages/zh.json b/inksight-mobile/messages/zh.json index 98348dfd..42f6df74 100644 --- a/inksight-mobile/messages/zh.json +++ b/inksight-mobile/messages/zh.json @@ -248,6 +248,11 @@ "device.configRefreshInterval": "刷新间隔(分钟)", "device.configModesBuiltin": "内置模式", "device.configModesCustomEmpty": "暂无自定义模式。可在「发现」或创作入口添加。", + "device.configScreenSize": "屏幕尺寸", + "device.configColors": "屏幕颜色", + "device.colorBW": "黑白", + "device.colorBWR": "黑白红", + "device.colorBWRY": "黑白红黄", "device.widgetTitle": "模式", "device.widgetFallback": "登录后可查看桌面组件预览数据。", "device.widgetLoginPrompt": "请先登录后再查看组件预览数据。", @@ -344,6 +349,14 @@ "ms.day5": "周六", "ms.day6": "周日", "ms.remove": "删除", + "ms.adaptiveTitle": "相框图片管理", + "ms.adaptiveHint": "上传至多 6 张图片,设备每次刷新时循环显示下一张。", + "ms.adaptiveAdd": "添加图片", + "ms.adaptiveRemove": "移除", + "ms.adaptiveUploading": "上传中...", + "ms.adaptiveUploadFailed": "上传失败", + "ms.adaptiveMaxReached": "最多只能添加 6 张图片", + "ms.adaptiveSelectPhoto": "选择照片", "firmware.title": "固件升级", "firmware.subtitle": "查看最新固件发布与安装包信息。", "firmware.selectVariant": "选择固件版本", diff --git a/inksight-mobile/package-lock.json b/inksight-mobile/package-lock.json index 4d092715..c85d6d99 100644 --- a/inksight-mobile/package-lock.json +++ b/inksight-mobile/package-lock.json @@ -17,6 +17,7 @@ "expo-file-system": "~55.0.10", "expo-font": "~55.0.4", "expo-haptics": "~55.0.8", + "expo-image-picker": "~55.0.0", "expo-linking": "~55.0.7", "expo-localization": "~55.0.8", "expo-notifications": "~55.0.12", @@ -4952,6 +4953,27 @@ } } }, + "node_modules/expo-image-loader": { + "version": "55.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-55.0.1.tgz", + "integrity": "sha512-o8gCo1j59XpXDh0/llgNYPcnfecYQhafQAO0yw5pb+kukPizvNoEqea8tFQIIQmNYqxd6Ljgs7lLXed0gXpOdQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "55.0.20", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-55.0.20.tgz", + "integrity": "sha512-lfWt/0rPWdKz8AdDEGmGHZIJSNlVc720Dlx5bfou10FU16ZV5wAbTU63nm2jkXd8hbXke4a/2Ha1dzxCVA+LQQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~55.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "55.0.4", "resolved": "https://registry.npmmirror.com/expo-keep-awake/-/expo-keep-awake-55.0.4.tgz", diff --git a/inksight-mobile/package.json b/inksight-mobile/package.json index 1bb85c01..a5c2fcc1 100644 --- a/inksight-mobile/package.json +++ b/inksight-mobile/package.json @@ -18,6 +18,7 @@ "expo-file-system": "~55.0.10", "expo-font": "~55.0.4", "expo-haptics": "~55.0.8", + "expo-image-picker": "~55.0.0", "expo-linking": "~55.0.7", "expo-localization": "~55.0.8", "expo-notifications": "~55.0.12", From 9e1b8c73a041d31196e7ad58720c8aea0d56b2f7 Mon Sep 17 00:00:00 2001 From: AeBoPi <109503402+AeBoPi@users.noreply.github.com> Date: Tue, 26 May 2026 17:14:23 +0800 Subject: [PATCH 2/8] feat: add vocabulary review mode --- backend/api/routes/device.py | 25 ++ backend/api/routes/render.py | 1 + backend/core/config_store.py | 6 + backend/core/json_content.py | 11 + backend/core/modes/builtin/vocab_review.json | 87 +++++ backend/core/vocab_data/core_en.json | 34 ++ backend/core/vocab_store.py | 363 +++++++++++++++++++ backend/migrations/__init__.py | 50 +++ backend/tests/test_vocab_review.py | 106 ++++++ docs/en/vocab-review.md | 48 +++ docs/vocab-review.md | 48 +++ firmware/platformio.ini | 16 +- firmware/src/config.h | 3 + firmware/src/main.cpp | 70 ++++ firmware/src/network.cpp | 40 ++ firmware/src/network.h | 1 + 16 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 backend/core/modes/builtin/vocab_review.json create mode 100644 backend/core/vocab_data/core_en.json create mode 100644 backend/core/vocab_store.py create mode 100644 backend/tests/test_vocab_review.py create mode 100644 docs/en/vocab-review.md create mode 100644 docs/vocab-review.md diff --git a/backend/api/routes/device.py b/backend/api/routes/device.py index 582a6541..59772b79 100644 --- a/backend/api/routes/device.py +++ b/backend/api/routes/device.py @@ -35,6 +35,7 @@ update_device_state, validate_alert_token, ) +from core.vocab_store import VOCAB_MODE_ID, handle_vocab_event from core.patterns.utils import apply_text_fontmode, load_font from core.renderer import image_to_bmp_bytes, image_to_png_bytes from core.schemas import DeviceHeartbeatRequest, OkResponse @@ -138,6 +139,30 @@ async def set_runtime_mode( return {"ok": True, "runtime_mode": mode} +@router.post("/device/{mac}/vocab/event") +async def vocab_review_event( + mac: str, + body: dict, + x_device_token: Optional[str] = Header(default=None), +): + mac = validate_mac_param(mac) + await require_device_token(mac, x_device_token) + action = str((body or {}).get("action") or "").strip().lower() + rating = str((body or {}).get("rating") or "").strip().lower() or None + cfg = await get_active_config(mac, log_load=False) + + if action == "enter": + result = await handle_vocab_event(mac, action, cfg, rating=rating) + await update_device_state(mac, pending_mode=VOCAB_MODE_ID, pending_refresh=1) + return result + + result = await handle_vocab_event(mac, action, cfg, rating=rating) + if not result.get("ok"): + return JSONResponse({"error": result.get("error") or "invalid_action"}, status_code=400) + await update_device_state(mac, pending_refresh=1) + return result + + @router.post("/device/{mac}/heartbeat", response_model=OkResponse) async def post_device_heartbeat( mac: str, diff --git a/backend/api/routes/render.py b/backend/api/routes/render.py index 6c9dd0e2..756e891b 100644 --- a/backend/api/routes/render.py +++ b/backend/api/routes/render.py @@ -219,6 +219,7 @@ async def render( headers: dict[str, str] = { "X-Render-Time-Ms": str(elapsed_ms), "X-Cache-Hit": "1" if cache_hit else "0", + "X-Mode-Id": resolved_persona, } if configured_refresh_minutes is not None: headers["X-Refresh-Minutes"] = str(configured_refresh_minutes) diff --git a/backend/core/config_store.py b/backend/core/config_store.py index 83bc686d..6c3deba6 100644 --- a/backend/core/config_store.py +++ b/backend/core/config_store.py @@ -553,6 +553,12 @@ async def init_db(): await _migrate_legacy_user_devices(db) await _fix_duplicate_owners(db) await db.commit() + try: + from .vocab_store import seed_builtin_vocab + + await seed_builtin_vocab() + except Exception: + logger.warning("[VOCAB] Failed to seed builtin vocabulary", exc_info=True) # ── User system ───────────────────────────────────────────── diff --git a/backend/core/json_content.py b/backend/core/json_content.py index d8794896..01964482 100644 --- a/backend/core/json_content.py +++ b/backend/core/json_content.py @@ -646,6 +646,7 @@ async def generate_json_mode_content( image_model=image_model, config=config or {}, date_ctx=date_ctx or {}, + mac=mac, api_key=api_key, image_api_key=image_api_key, ) @@ -1074,6 +1075,16 @@ async def _generate_computed_content(mode_def: dict, content_cfg: dict, fallback items = [{"title": default_title, "text": "1. \n2. \n3. "}] return {"memo_items": items} + if provider == "vocab_review": + from .vocab_store import get_vocab_content + + mac = str(kwargs.get("mac") or "").strip() + if not mac: + return dict(fallback) + result = dict(fallback) + result.update(await get_vocab_content(mac, kwargs.get("config") or {})) + return result + if provider == "habit": config = kwargs.get("config") or {} lang = kwargs.get("language", "zh") diff --git a/backend/core/modes/builtin/vocab_review.json b/backend/core/modes/builtin/vocab_review.json new file mode 100644 index 00000000..27972392 --- /dev/null +++ b/backend/core/modes/builtin/vocab_review.json @@ -0,0 +1,87 @@ +{ + "mode_id": "VOCAB_REVIEW", + "display_name": "背单词", + "icon": "book", + "cacheable": false, + "description": "单按键间隔重复背词卡片", + "settings_schema": [ + {"key": "deck_id", "label": "词库", "type": "select", "default": "core_en", "options": [{"label": "核心英语", "value": "core_en"}]}, + {"key": "daily_limit", "label": "每日上限", "type": "number", "default": 30, "min": 1, "max": 200}, + {"key": "new_cards_per_day", "label": "每日新词", "type": "number", "default": 10, "min": 0, "max": 100} + ], + "content": { + "type": "computed", + "provider": "vocab_review", + "fallback": { + "state": "empty", + "word": "VOCAB", + "phonetic": "", + "definition": "暂无词卡", + "example": "", + "progress": "0/30", + "rating_label": "", + "rating_hint": "" + } + }, + "layout": { + "status_bar": {"line_width": 1, "dashed": false}, + "body": [ + {"type": "text", "template": "VOCAB {progress}", "font": "noto_serif_regular", "font_size": 12, "align": "center", "margin_x": 20, "max_lines": 1}, + {"type": "spacer", "height": 12}, + {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 42, "align": "center", "margin_x": 18, "max_lines": 1}, + {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_size": 16, "align": "center", "margin_x": 24, "max_lines": 1}, + { + "type": "conditional", + "field": "state", + "conditions": [ + { + "op": "eq", + "value": "back", + "children": [ + {"type": "separator", "style": "short", "width": 70, "line_width": 1}, + {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 18, "align": "center", "margin_x": 32, "max_lines": 2}, + {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 12, "align": "center", "margin_x": 38, "max_lines": 2}, + {"type": "spacer", "height": 6}, + {"type": "text", "template": "评分:{rating_label}", "font": "noto_serif_bold", "font_size": 18, "align": "center", "margin_x": 20, "max_lines": 1}, + {"type": "text", "field": "rating_hint", "font": "noto_serif_light", "font_size": 10, "align": "center", "margin_x": 20, "max_lines": 1} + ] + }, + { + "op": "eq", + "value": "empty", + "children": [ + {"type": "separator", "style": "short", "width": 70, "line_width": 1}, + {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 18, "align": "center", "margin_x": 30, "max_lines": 2}, + {"type": "text", "field": "rating_hint", "font": "noto_serif_light", "font_size": 12, "align": "center", "margin_x": 30, "max_lines": 1} + ] + } + ], + "fallback_children": [ + {"type": "spacer", "height": 28}, + {"type": "text", "template": "短按翻面", "font": "noto_serif_light", "font_size": 14, "align": "center", "margin_x": 20, "max_lines": 1} + ] + } + ], + "footer": {"label": "VOCAB"} + }, + "layout_overrides": { + "296x128": { + "body": [ + {"type": "text", "template": "VOCAB {progress}", "font": "noto_serif_regular", "font_size": 9, "align": "center", "margin_x": 8, "max_lines": 1}, + {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 25, "align": "center", "margin_x": 8, "max_lines": 1}, + {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_size": 10, "align": "center", "margin_x": 8, "max_lines": 1}, + {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 12, "align": "center", "margin_x": 10, "max_lines": 1}, {"type": "text", "template": "{rating_label}", "font": "noto_serif_bold", "font_size": 13, "align": "center", "margin_x": 8, "max_lines": 1}]}], "fallback_children": [{"type": "text", "template": "短按翻面", "font": "noto_serif_light", "font_size": 10, "align": "center", "margin_x": 8, "max_lines": 1}]} + ], + "footer": {"label": "VOCAB", "height": 18} + }, + "648x480": { + "body": [ + {"type": "text", "template": "VOCAB {progress}", "font": "noto_serif_regular", "font_size": 16, "align": "center", "margin_x": 24, "max_lines": 1}, + {"type": "spacer", "height": 22}, + {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 62, "align": "center", "margin_x": 24, "max_lines": 1}, + {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_size": 22, "align": "center", "margin_x": 30, "max_lines": 1}, + {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 90, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 28, "align": "center", "margin_x": 52, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 18, "align": "center", "margin_x": 70, "max_lines": 2}, {"type": "text", "template": "评分:{rating_label}", "font": "noto_serif_bold", "font_size": 28, "align": "center", "margin_x": 30, "max_lines": 1}]}], "fallback_children": [{"type": "spacer", "height": 44}, {"type": "text", "template": "短按翻面", "font": "noto_serif_light", "font_size": 20, "align": "center", "margin_x": 30, "max_lines": 1}]} + ] + } + } +} diff --git a/backend/core/vocab_data/core_en.json b/backend/core/vocab_data/core_en.json new file mode 100644 index 00000000..918908b2 --- /dev/null +++ b/backend/core/vocab_data/core_en.json @@ -0,0 +1,34 @@ +[ + {"deck_id":"core_en","word":"abandon","phonetic":"/əˈbændən/","definition":"放弃;抛弃","example":"Do not abandon your plan after one setback.","difficulty":1}, + {"deck_id":"core_en","word":"ability","phonetic":"/əˈbɪləti/","definition":"能力;才能","example":"Reading daily improves your language ability.","difficulty":1}, + {"deck_id":"core_en","word":"absorb","phonetic":"/əbˈzɔːrb/","definition":"吸收;理解","example":"Paper towels absorb water quickly.","difficulty":1}, + {"deck_id":"core_en","word":"accurate","phonetic":"/ˈækjərət/","definition":"准确的;精确的","example":"The map is accurate enough for the trip.","difficulty":1}, + {"deck_id":"core_en","word":"achieve","phonetic":"/əˈtʃiːv/","definition":"实现;达到","example":"She worked hard to achieve her goal.","difficulty":1}, + {"deck_id":"core_en","word":"adapt","phonetic":"/əˈdæpt/","definition":"适应;改编","example":"Children adapt quickly to new routines.","difficulty":1}, + {"deck_id":"core_en","word":"afford","phonetic":"/əˈfɔːrd/","definition":"负担得起;提供","example":"We cannot afford to waste time.","difficulty":1}, + {"deck_id":"core_en","word":"analyze","phonetic":"/ˈænəlaɪz/","definition":"分析","example":"Analyze the problem before choosing a solution.","difficulty":2}, + {"deck_id":"core_en","word":"approach","phonetic":"/əˈproʊtʃ/","definition":"方法;接近","example":"This approach makes the task easier.","difficulty":2}, + {"deck_id":"core_en","word":"benefit","phonetic":"/ˈbenɪfɪt/","definition":"好处;受益","example":"Regular sleep has a clear benefit.","difficulty":1}, + {"deck_id":"core_en","word":"brief","phonetic":"/briːf/","definition":"简短的;简要介绍","example":"Keep your answer brief and specific.","difficulty":1}, + {"deck_id":"core_en","word":"challenge","phonetic":"/ˈtʃælɪndʒ/","definition":"挑战;质疑","example":"The new project is a real challenge.","difficulty":1}, + {"deck_id":"core_en","word":"combine","phonetic":"/kəmˈbaɪn/","definition":"结合;合并","example":"Combine the flour and water slowly.","difficulty":1}, + {"deck_id":"core_en","word":"concept","phonetic":"/ˈkɑːnsept/","definition":"概念","example":"The concept is simple but powerful.","difficulty":2}, + {"deck_id":"core_en","word":"confirm","phonetic":"/kənˈfɜːrm/","definition":"确认;证实","example":"Please confirm the meeting time.","difficulty":1}, + {"deck_id":"core_en","word":"context","phonetic":"/ˈkɑːntekst/","definition":"语境;背景","example":"The meaning depends on the context.","difficulty":2}, + {"deck_id":"core_en","word":"decline","phonetic":"/dɪˈklaɪn/","definition":"下降;婉拒","example":"Sales began to decline in winter.","difficulty":2}, + {"deck_id":"core_en","word":"define","phonetic":"/dɪˈfaɪn/","definition":"定义;明确","example":"Define the goal before starting.","difficulty":1}, + {"deck_id":"core_en","word":"deliver","phonetic":"/dɪˈlɪvər/","definition":"递送;交付;发表","example":"The team will deliver the update today.","difficulty":1}, + {"deck_id":"core_en","word":"efficient","phonetic":"/ɪˈfɪʃnt/","definition":"高效的","example":"This tool makes the workflow more efficient.","difficulty":2}, + {"deck_id":"core_en","word":"essential","phonetic":"/ɪˈsenʃl/","definition":"必要的;本质的","example":"Clean water is essential for life.","difficulty":2}, + {"deck_id":"core_en","word":"evidence","phonetic":"/ˈevɪdəns/","definition":"证据","example":"The report provides strong evidence.","difficulty":2}, + {"deck_id":"core_en","word":"expand","phonetic":"/ɪkˈspænd/","definition":"扩大;扩展","example":"The company plans to expand overseas.","difficulty":1}, + {"deck_id":"core_en","word":"feature","phonetic":"/ˈfiːtʃər/","definition":"特征;功能","example":"Dark mode is a popular feature.","difficulty":1}, + {"deck_id":"core_en","word":"flexible","phonetic":"/ˈfleksəbl/","definition":"灵活的","example":"A flexible schedule helps parents.","difficulty":2}, + {"deck_id":"core_en","word":"generate","phonetic":"/ˈdʒenəreɪt/","definition":"生成;产生","example":"The app can generate a preview.","difficulty":2}, + {"deck_id":"core_en","word":"identify","phonetic":"/aɪˈdentɪfaɪ/","definition":"识别;确认","example":"Identify the root cause first.","difficulty":2}, + {"deck_id":"core_en","word":"improve","phonetic":"/ɪmˈpruːv/","definition":"改善;提高","example":"Practice will improve your pronunciation.","difficulty":1}, + {"deck_id":"core_en","word":"maintain","phonetic":"/meɪnˈteɪn/","definition":"维持;维护","example":"Maintain a steady pace.","difficulty":2}, + {"deck_id":"core_en","word":"priority","phonetic":"/praɪˈɔːrəti/","definition":"优先事项","example":"Security is our top priority.","difficulty":2}, + {"deck_id":"core_en","word":"require","phonetic":"/rɪˈkwaɪər/","definition":"需要;要求","example":"This task requires careful testing.","difficulty":1}, + {"deck_id":"core_en","word":"resolve","phonetic":"/rɪˈzɑːlv/","definition":"解决;决定","example":"We need to resolve the issue quickly.","difficulty":2} +] diff --git a/backend/core/vocab_store.py b/backend/core/vocab_store.py new file mode 100644 index 00000000..a8036834 --- /dev/null +++ b/backend/core/vocab_store.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from .db import get_main_db + +VOCAB_MODE_ID = "VOCAB_REVIEW" +DEFAULT_DECK_ID = "core_en" +DEFAULT_DAILY_LIMIT = 30 +DEFAULT_NEW_CARDS_PER_DAY = 10 +RATINGS = ("forgot", "fuzzy", "remember") +RATING_LABELS = { + "forgot": "忘了", + "fuzzy": "模糊", + "remember": "记住", +} + +_DATA_PATH = Path(__file__).resolve().parent / "vocab_data" / "core_en.json" + + +async def seed_builtin_vocab() -> None: + if not _DATA_PATH.exists(): + return + try: + items = json.loads(_DATA_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + if not isinstance(items, list): + return + + now = datetime.now().isoformat() + db = await get_main_db() + for item in items: + if not isinstance(item, dict): + continue + word = str(item.get("word") or "").strip() + definition = str(item.get("definition") or "").strip() + if not word or not definition: + continue + await db.execute( + """ + INSERT OR IGNORE INTO vocab_items + (deck_id, word, phonetic, definition, example, difficulty, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + str(item.get("deck_id") or DEFAULT_DECK_ID), + word, + str(item.get("phonetic") or ""), + definition, + str(item.get("example") or ""), + int(item.get("difficulty") or 1), + now, + ), + ) + await db.commit() + + +def _mode_settings(config: dict[str, Any] | None) -> dict[str, Any]: + config = config or {} + settings = config.get("mode_settings") + if isinstance(settings, dict): + return settings + overrides = config.get("mode_overrides") + if isinstance(overrides, dict): + override = overrides.get(VOCAB_MODE_ID) + if isinstance(override, dict): + return override + return {} + + +def resolve_vocab_settings(config: dict[str, Any] | None) -> tuple[str, int, int]: + settings = _mode_settings(config) + deck_id = str(settings.get("deck_id") or DEFAULT_DECK_ID).strip() or DEFAULT_DECK_ID + daily_limit = _bounded_int(settings.get("daily_limit"), DEFAULT_DAILY_LIMIT, 1, 200) + new_cards = _bounded_int(settings.get("new_cards_per_day"), DEFAULT_NEW_CARDS_PER_DAY, 0, daily_limit) + return deck_id, daily_limit, new_cards + + +def _bounded_int(value: Any, default: int, min_value: int, max_value: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + return max(min_value, min(max_value, parsed)) + + +async def ensure_vocab_session(mac: str, config: dict[str, Any] | None = None) -> dict[str, Any]: + deck_id, daily_limit, new_cards_per_day = resolve_vocab_settings(config) + today = datetime.now().date().isoformat() + now = datetime.now().isoformat() + db = await get_main_db() + + cursor = await db.execute("SELECT * FROM vocab_session_state WHERE mac = ?", (mac,)) + row = await cursor.fetchone() + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + session = dict(zip(columns, row)) if row else None + if not session: + await db.execute( + """ + INSERT INTO vocab_session_state + (mac, deck_id, side, rating_cursor, review_date, reviewed_count, new_count, updated_at) + VALUES (?, ?, 'front', 0, ?, 0, 0, ?) + """, + (mac, deck_id, today, now), + ) + await db.commit() + session = { + "mac": mac, + "deck_id": deck_id, + "current_item_id": None, + "side": "front", + "rating_cursor": 0, + "review_date": today, + "reviewed_count": 0, + "new_count": 0, + } + elif session.get("review_date") != today or session.get("deck_id") != deck_id: + session.update({"deck_id": deck_id, "review_date": today, "reviewed_count": 0, "new_count": 0}) + await db.execute( + """ + UPDATE vocab_session_state + SET deck_id = ?, review_date = ?, reviewed_count = 0, new_count = 0, updated_at = ? + WHERE mac = ? + """, + (deck_id, today, now, mac), + ) + await db.commit() + + current = await _get_current_item(db, session.get("current_item_id"), deck_id) + if current and int(session.get("reviewed_count") or 0) < daily_limit: + return {**session, "item": current, "daily_limit": daily_limit, "new_cards_per_day": new_cards_per_day} + + item, is_new = await _select_next_item(db, mac, deck_id, daily_limit, new_cards_per_day, session) + if item: + await db.execute( + """ + UPDATE vocab_session_state + SET current_item_id = ?, side = 'front', rating_cursor = 0, updated_at = ? + WHERE mac = ? + """, + (item["id"], now, mac), + ) + await db.commit() + session.update({"current_item_id": item["id"], "side": "front", "rating_cursor": 0, "current_is_new": is_new}) + else: + await db.execute( + "UPDATE vocab_session_state SET current_item_id = NULL, side = 'front', rating_cursor = 0, updated_at = ? WHERE mac = ?", + (now, mac), + ) + await db.commit() + session.update({"current_item_id": None, "side": "front", "rating_cursor": 0, "current_is_new": False}) + return {**session, "item": item, "daily_limit": daily_limit, "new_cards_per_day": new_cards_per_day} + + +async def handle_vocab_event(mac: str, action: str, config: dict[str, Any] | None = None, rating: str | None = None) -> dict[str, Any]: + action = str(action or "").strip().lower() + if action == "enter": + await ensure_vocab_session(mac, config) + return {"ok": True, "action": action} + + session = await ensure_vocab_session(mac, config) + now = datetime.now().isoformat() + db = await get_main_db() + if action == "flip": + await db.execute("UPDATE vocab_session_state SET side = 'back', updated_at = ? WHERE mac = ?", (now, mac)) + await db.commit() + elif action == "next_rating": + cursor = (int(session.get("rating_cursor") or 0) + 1) % len(RATINGS) + await db.execute("UPDATE vocab_session_state SET rating_cursor = ?, updated_at = ? WHERE mac = ?", (cursor, now, mac)) + await db.commit() + elif action == "submit_rating": + item = session.get("item") + if item: + selected = str(rating or RATINGS[int(session.get("rating_cursor") or 0) % len(RATINGS)]) + if selected not in RATINGS: + selected = "fuzzy" + was_new = await _is_current_new(db, mac, item["id"]) + await _apply_rating(db, mac, int(item["id"]), selected) + await _advance_session(db, mac, config, was_new=was_new) + else: + return {"ok": False, "error": "invalid_action"} + return {"ok": True, "action": action} + + +async def get_vocab_content(mac: str, config: dict[str, Any] | None = None) -> dict[str, Any]: + session = await ensure_vocab_session(mac, config) + item = session.get("item") + reviewed = int(session.get("reviewed_count") or 0) + daily_limit = int(session.get("daily_limit") or DEFAULT_DAILY_LIMIT) + if not item: + return { + "state": "empty", + "word": "今日完成", + "phonetic": "", + "definition": "没有到期或新词卡了", + "example": "", + "progress": f"{reviewed}/{daily_limit}", + "rating_label": "", + "rating_hint": "明天再来", + } + rating = RATINGS[int(session.get("rating_cursor") or 0) % len(RATINGS)] + return { + "state": str(session.get("side") or "front"), + "word": item["word"], + "phonetic": item.get("phonetic") or "", + "definition": item["definition"], + "example": item.get("example") or "", + "progress": f"{reviewed}/{daily_limit}", + "rating_label": RATING_LABELS[rating], + "rating_hint": "短按切换评分,长按提交", + } + + +async def _get_current_item(db, item_id: Any, deck_id: str) -> dict[str, Any] | None: + if not item_id: + return None + cursor = await db.execute( + "SELECT id, deck_id, word, phonetic, definition, example, difficulty FROM vocab_items WHERE id = ? AND deck_id = ?", + (item_id, deck_id), + ) + row = await cursor.fetchone() + return _item_from_row(row) if row else None + + +async def _select_next_item(db, mac: str, deck_id: str, daily_limit: int, new_cards_per_day: int, session: dict[str, Any]) -> tuple[dict[str, Any] | None, bool]: + if int(session.get("reviewed_count") or 0) >= daily_limit: + return None, False + now = datetime.now().isoformat() + cursor = await db.execute( + """ + SELECT vi.id, vi.deck_id, vi.word, vi.phonetic, vi.definition, vi.example, vi.difficulty + FROM vocab_progress vp + JOIN vocab_items vi ON vi.id = vp.vocab_item_id + WHERE vp.mac = ? AND vi.deck_id = ? AND vp.due_at <= ? + ORDER BY vp.due_at ASC, vi.difficulty ASC, vi.id ASC + LIMIT 1 + """, + (mac, deck_id, now), + ) + row = await cursor.fetchone() + if row: + return _item_from_row(row), False + + if int(session.get("new_count") or 0) >= new_cards_per_day: + return None, False + cursor = await db.execute( + """ + SELECT vi.id, vi.deck_id, vi.word, vi.phonetic, vi.definition, vi.example, vi.difficulty + FROM vocab_items vi + LEFT JOIN vocab_progress vp ON vp.vocab_item_id = vi.id AND vp.mac = ? + WHERE vi.deck_id = ? AND vp.vocab_item_id IS NULL + ORDER BY vi.difficulty ASC, vi.id ASC + LIMIT 1 + """, + (mac, deck_id), + ) + row = await cursor.fetchone() + return (_item_from_row(row), True) if row else (None, False) + + +def _item_from_row(row) -> dict[str, Any]: + return { + "id": row[0], + "deck_id": row[1], + "word": row[2], + "phonetic": row[3], + "definition": row[4], + "example": row[5], + "difficulty": row[6], + } + + +async def _apply_rating(db, mac: str, item_id: int, rating: str) -> None: + now_dt = datetime.now() + now = now_dt.isoformat() + cursor = await db.execute( + """ + SELECT interval_days, ease_factor, repetitions, lapses + FROM vocab_progress WHERE mac = ? AND vocab_item_id = ? + """, + (mac, item_id), + ) + row = await cursor.fetchone() + interval = int(row[0]) if row else 0 + ease = float(row[1]) if row else 2.5 + reps = int(row[2]) if row else 0 + lapses = int(row[3]) if row else 0 + + if rating == "forgot": + interval = 0 + reps = 0 + lapses += 1 + ease = max(1.3, ease - 0.2) + due_at = now_dt + timedelta(minutes=10) + elif rating == "fuzzy": + interval = max(1, interval) + reps = max(1, reps) + ease = max(1.3, ease - 0.1) + due_at = now_dt + timedelta(days=interval) + else: + reps += 1 + if reps == 1: + interval = 1 + elif reps == 2: + interval = 6 + else: + interval = max(1, round(interval * ease)) + ease = ease + (0.1 - (5 - 5) * (0.08 + (5 - 5) * 0.02)) + due_at = now_dt + timedelta(days=interval) + + await db.execute( + """ + INSERT INTO vocab_progress + (mac, vocab_item_id, due_at, interval_days, ease_factor, repetitions, lapses, last_grade, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(mac, vocab_item_id) DO UPDATE SET + due_at = excluded.due_at, + interval_days = excluded.interval_days, + ease_factor = excluded.ease_factor, + repetitions = excluded.repetitions, + lapses = excluded.lapses, + last_grade = excluded.last_grade, + updated_at = excluded.updated_at + """, + (mac, item_id, due_at.isoformat(), interval, ease, reps, lapses, rating, now), + ) + + +async def _advance_session(db, mac: str, config: dict[str, Any] | None, *, was_new: bool) -> None: + cursor = await db.execute("SELECT * FROM vocab_session_state WHERE mac = ?", (mac,)) + row = await cursor.fetchone() + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + session = dict(zip(columns, row)) if row else {} + deck_id, daily_limit, new_cards_per_day = resolve_vocab_settings(config) + reviewed = int(session.get("reviewed_count") or 0) + 1 + new_count = int(session.get("new_count") or 0) + (1 if was_new else 0) + session.update({"reviewed_count": reviewed, "new_count": new_count, "deck_id": deck_id}) + item, _is_new = await _select_next_item(db, mac, deck_id, daily_limit, new_cards_per_day, session) + now = datetime.now().isoformat() + await db.execute( + """ + UPDATE vocab_session_state + SET current_item_id = ?, side = 'front', rating_cursor = 0, + reviewed_count = ?, new_count = ?, updated_at = ? + WHERE mac = ? + """, + (item["id"] if item else None, reviewed, new_count, now, mac), + ) + await db.commit() + + +async def _is_current_new(db, mac: str, item_id: Any) -> bool: + if not item_id: + return False + cursor = await db.execute( + "SELECT 1 FROM vocab_progress WHERE mac = ? AND vocab_item_id = ?", + (mac, item_id), + ) + return await cursor.fetchone() is None diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py index f3d64a29..b41cdafd 100644 --- a/backend/migrations/__init__.py +++ b/backend/migrations/__init__.py @@ -116,6 +116,56 @@ async def run_main_db_migrations(db, *, defaults: dict[str, str]) -> None: """ ), ), + ( + 25, + "vocab_review.create", + lambda: db.executescript( + """ + CREATE TABLE IF NOT EXISTS vocab_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deck_id TEXT NOT NULL, + word TEXT NOT NULL, + phonetic TEXT DEFAULT '', + definition TEXT NOT NULL, + example TEXT DEFAULT '', + difficulty INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + UNIQUE(deck_id, word) + ); + CREATE INDEX IF NOT EXISTS idx_vocab_items_deck + ON vocab_items(deck_id, difficulty, id); + + CREATE TABLE IF NOT EXISTS vocab_progress ( + mac TEXT NOT NULL, + vocab_item_id INTEGER NOT NULL, + due_at TEXT NOT NULL, + interval_days INTEGER DEFAULT 0, + ease_factor REAL DEFAULT 2.5, + repetitions INTEGER DEFAULT 0, + lapses INTEGER DEFAULT 0, + last_grade TEXT DEFAULT '', + updated_at TEXT NOT NULL, + PRIMARY KEY(mac, vocab_item_id), + FOREIGN KEY(vocab_item_id) REFERENCES vocab_items(id) + ); + CREATE INDEX IF NOT EXISTS idx_vocab_progress_due + ON vocab_progress(mac, due_at); + + CREATE TABLE IF NOT EXISTS vocab_session_state ( + mac TEXT PRIMARY KEY, + deck_id TEXT DEFAULT 'core_en', + current_item_id INTEGER, + side TEXT DEFAULT 'front', + rating_cursor INTEGER DEFAULT 0, + review_date TEXT DEFAULT '', + reviewed_count INTEGER DEFAULT 0, + new_count INTEGER DEFAULT 0, + updated_at TEXT NOT NULL, + FOREIGN KEY(current_item_id) REFERENCES vocab_items(id) + ); + """ + ), + ), ] now = datetime.now().isoformat() diff --git a/backend/tests/test_vocab_review.py b/backend/tests/test_vocab_review.py new file mode 100644 index 00000000..a7c48347 --- /dev/null +++ b/backend/tests/test_vocab_review.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import pytest +from httpx import AsyncClient +from unittest.mock import patch + +from api.index import app +from core.config_store import get_device_state +from core.config_store import init_db +from core.db import get_main_db +from core.vocab_store import get_vocab_content, handle_vocab_event + + +@pytest.fixture +async def isolated_db(tmp_path): + from core import db as db_mod + + await db_mod.close_all() + test_main_db = str(tmp_path / "test_inksight.db") + test_cache_db = str(tmp_path / "test_cache.db") + with patch.object(db_mod, "_MAIN_DB_PATH", test_main_db), \ + patch.object(db_mod, "_CACHE_DB_PATH", test_cache_db), \ + patch("core.config_store.DB_PATH", test_main_db), \ + patch("core.stats_store.DB_PATH", test_main_db), \ + patch("core.cache._CACHE_DB_PATH", test_cache_db): + await init_db() + yield + await db_mod.close_all() + + +@pytest.fixture +async def client(isolated_db): + try: + from httpx import ASGITransport + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + except Exception: + async with AsyncClient(app=app, base_url="http://test") as c: + yield c + + +@pytest.mark.asyncio +async def test_vocab_tables_seeded_by_init_db(isolated_db): + db = await get_main_db() + for table in ("vocab_items", "vocab_progress", "vocab_session_state"): + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ) + assert await cursor.fetchone() + cursor = await db.execute("SELECT COUNT(*) FROM vocab_items WHERE deck_id = 'core_en'") + assert (await cursor.fetchone())[0] >= 10 + + +@pytest.mark.asyncio +async def test_vocab_session_flip_rating_and_submit(isolated_db): + mac = "AA:BB:CC:DD:EE:01" + content = await get_vocab_content(mac, {"mode_overrides": {"VOCAB_REVIEW": {"daily_limit": 5, "new_cards_per_day": 2}}}) + assert content["state"] == "front" + assert content["word"] + + await handle_vocab_event(mac, "flip", {}) + content = await get_vocab_content(mac, {}) + assert content["state"] == "back" + assert content["rating_label"] == "忘了" + + await handle_vocab_event(mac, "next_rating", {}) + content = await get_vocab_content(mac, {}) + assert content["rating_label"] == "模糊" + + previous_word = content["word"] + await handle_vocab_event(mac, "submit_rating", {}, rating="remember") + content = await get_vocab_content(mac, {}) + assert content["state"] == "front" + assert content["progress"].startswith("1/") + assert content["word"] != previous_word + + db = await get_main_db() + cursor = await db.execute("SELECT interval_days, repetitions, last_grade FROM vocab_progress WHERE mac = ?", (mac,)) + row = await cursor.fetchone() + assert row[0] == 1 + assert row[1] == 1 + assert row[2] == "remember" + + +@pytest.mark.asyncio +async def test_vocab_event_api_requires_token_and_sets_pending_mode(client): + mac = "AA:BB:CC:DD:EE:02" + unauthorized = await client.post(f"/api/device/{mac}/vocab/event", json={"action": "enter"}) + assert unauthorized.status_code == 401 + + token_resp = await client.post(f"/api/device/{mac}/token") + token = token_resp.json()["token"] + headers = {"X-Device-Token": token} + resp = await client.post(f"/api/device/{mac}/vocab/event", json={"action": "enter"}, headers=headers) + assert resp.status_code == 200 + state = await get_device_state(mac) + assert state["pending_mode"] == "VOCAB_REVIEW" + assert state["pending_refresh"] == 1 + + resp = await client.post(f"/api/device/{mac}/vocab/event", json={"action": "flip"}, headers=headers) + assert resp.status_code == 200 + state = await get_device_state(mac) + assert state["pending_refresh"] == 1 diff --git a/docs/en/vocab-review.md b/docs/en/vocab-review.md new file mode 100644 index 00000000..0c93ad65 --- /dev/null +++ b/docs/en/vocab-review.md @@ -0,0 +1,48 @@ +# Vocabulary Review Mode + +`VOCAB_REVIEW` is a built-in spaced repetition mode. Progress is stored per device MAC. The first version uses the bundled `core_en` deck and does not include spoken prompts or automatic answer checking. + +## Firmware Environment + +Dedicated vocabulary firmware environment: + +```bash +platformio run -e epd_42_wroom32e_vocab_review +``` + +This target is for ESP32-WROOM32E with a 4.2-inch 400x300 black-and-white display. The function button uses `GPIO23`: connect one side to `GPIO23` and the other side to `GND`. This is a vocabulary-specific button firmware, separate from `epd_42_wroom32e_ai_chat`. + +## Button Controls + +- Outside vocabulary mode: long press enters vocabulary review. +- Front side: short press flips the card. +- Back side: short press cycles `Forgot / Fuzzy / Remember`. +- Back side: long press submits the selected rating and advances. + +## Ratings + +- `forgot`: due again in 10 minutes and records a lapse. +- `fuzzy`: due at least 1 day later, with a small ease decrease. +- `remember`: uses simplified SM-2 intervals: 1 day, 6 days, then grows by ease factor. + +## Mode Settings + +- `deck_id`: deck ID, default `core_en`. +- `daily_limit`: daily review cap, default `30`. +- `new_cards_per_day`: daily new-card cap, default `10`. + +## Device API + +```http +POST /api/device/{mac}/vocab/event +X-Device-Token: +Content-Type: application/json +``` + +Example: + +```json +{"action":"enter"} +``` + +Supported actions: `enter`, `flip`, `next_rating`, `submit_rating`. Rating submission may include `rating`: `forgot`, `fuzzy`, or `remember`. diff --git a/docs/vocab-review.md b/docs/vocab-review.md new file mode 100644 index 00000000..01e4ae12 --- /dev/null +++ b/docs/vocab-review.md @@ -0,0 +1,48 @@ +# 背单词模式 + +`VOCAB_REVIEW` 是内置背词模式,使用设备 MAC 独立保存进度。第一版使用项目内置 `core_en` 基础词库,不包含语音读词或自动判答。 + +## 固件环境 + +背词专用固件环境: + +```bash +platformio run -e epd_42_wroom32e_vocab_review +``` + +该环境面向 ESP32-WROOM32E + 4.2 寸 400x300 黑白屏,功能键沿用 `GPIO23`,接法为一端接 `GPIO23`、另一端接 `GND`。它是背词专用功能键固件,不同于 `epd_42_wroom32e_ai_chat`。 + +## 按键语义 + +- 非背词模式:长按功能键进入背单词模式。 +- 正面:短按翻到释义面。 +- 反面:短按在 `忘了 / 模糊 / 记住` 间切换评分。 +- 反面:长按提交当前评分并进入下一词。 + +## 评分语义 + +- `忘了`:10 分钟后再次复习,并记录一次遗忘。 +- `模糊`:至少 1 天后复习,熟练度略降。 +- `记住`:按简化 SM-2 增加间隔,首次 1 天,第二次 6 天,之后按熟练度增长。 + +## 模式设置 + +- `deck_id`:词库 ID,默认 `core_en`。 +- `daily_limit`:每日复习总上限,默认 `30`。 +- `new_cards_per_day`:每日新词上限,默认 `10`。 + +## 设备 API + +```http +POST /api/device/{mac}/vocab/event +X-Device-Token: +Content-Type: application/json +``` + +请求示例: + +```json +{"action":"enter"} +``` + +支持的 `action`:`enter`、`flip`、`next_rating`、`submit_rating`。提交评分时可传 `rating`:`forgot`、`fuzzy`、`remember`。 diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 1ab95751..f932a946 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -66,6 +66,20 @@ build_flags = -DALLOW_INSECURE_FALLBACK=0 -DAUTO_BOOT_AI_CHAT=1 +# ── ESP32-WROOM32E + vocabulary review dedicated button ───── +[env:epd_42_wroom32e_vocab_review] +extends = common +board = esp32dev +upload_speed = 460800 +build_flags = + -DBOARD_PROFILE_ESP32_WROOM32E + -DEPD_WIDTH=400 + -DEPD_HEIGHT=300 + -DEPD_PANEL_42_SSD1683_BW + -DALLOW_INSECURE_FALLBACK=0 + -DVOCAB_REVIEW_BUILD=1 + -DAUTO_BOOT_AI_CHAT=0 + # ── 中景园4.2" SSD1683 BW panels 软件模拟SPI驱动──────────────────────────────────── [env:epd_42_zhongjingyuan_bw_ssd1683_c3_promini] extends = common @@ -456,4 +470,4 @@ build_flags = -DEPD_HEIGHT=300 -DEPD_PANEL_42_WFT -DEPD_BPP=2 - -DALLOW_INSECURE_FALLBACK=0 \ No newline at end of file + -DALLOW_INSECURE_FALLBACK=0 diff --git a/firmware/src/config.h b/firmware/src/config.h index b178fdce..8d0f1e07 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -141,5 +141,8 @@ static const int DEBUG_REFRESH_MIN = 1; // 1 minute for debugging #ifndef AUTO_BOOT_AI_CHAT #define AUTO_BOOT_AI_CHAT 0 #endif +#ifndef VOCAB_REVIEW_BUILD +#define VOCAB_REVIEW_BUILD 0 +#endif #endif // INKSIGHT_CONFIG_H diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index b33aa571..2c964d16 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -44,6 +44,7 @@ bool ensureColorBuf() { // ── Voice constants ───────────────────────────────────────── static const char *AI_CHAT_MODE_ID = "AI_CHAT"; +static const char *VOCAB_REVIEW_MODE_ID = "VOCAB_REVIEW"; static const int VOICE_SILENCE_COMMIT_MS = 600; static const float VOICE_STREAM_VAD_THRESHOLD = 150.0f; static const unsigned long VOICE_MAX_CAPTURE_MS = 8000; @@ -105,6 +106,12 @@ struct DeviceContext { bool wantEnterLiveMode = false; bool wantEnterAiChatMode = false; bool wantSingleVoiceTurn = false; + bool wantEnterVocabReview = false; + bool wantVocabFlip = false; + bool wantVocabNextRating = false; + bool wantVocabSubmitRating = false; + bool vocabReviewBackSide = false; + String currentRenderedModeId; String switchToModeId; }; @@ -787,6 +794,12 @@ void setup() { ctx.lastClockTick = millis(); bool aiChatRequested = renderedModeId.equalsIgnoreCase(AI_CHAT_MODE_ID); + if (renderedModeId.length() > 0) { + ctx.currentRenderedModeId = renderedModeId; + if (!ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID)) { + ctx.vocabReviewBackSide = false; + } + } if (aiChatRequested) { g_userAborted = false; bool exited = runAiChatConversation(); @@ -873,7 +886,36 @@ void loop() { postRuntimeMode("active"); } } +#if VOCAB_REVIEW_BUILD + } else if (ctx.wantEnterVocabReview || ctx.wantVocabFlip || ctx.wantVocabNextRating || ctx.wantVocabSubmitRating) { + const char *action = ctx.wantEnterVocabReview ? "enter" : + (ctx.wantVocabFlip ? "flip" : + (ctx.wantVocabNextRating ? "next_rating" : "submit_rating")); + ctx.wantEnterVocabReview = false; + ctx.wantVocabFlip = false; + ctx.wantVocabNextRating = false; + ctx.wantVocabSubmitRating = false; + ledFeedback("ack"); + if (WiFi.status() != WL_CONNECTED && !connectWiFi()) { + Serial.println("[VOCAB] WiFi reconnect failed, skip"); + } else if (postVocabEvent(action)) { + if (strcmp(action, "enter") == 0 || strcmp(action, "submit_rating") == 0) { + ctx.vocabReviewBackSide = false; + } else if (strcmp(action, "flip") == 0) { + ctx.vocabReviewBackSide = true; + } + lastContentChecksum = 0; + triggerImmediateRefresh(false, true); + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + ctx.setupDoneAt = millis(); + } else { + ledFeedback("fail"); + } + } else if (ctx.wantSingleVoiceTurn) { +#else } else if (ctx.wantSingleVoiceTurn) { +#endif ctx.wantSingleVoiceTurn = false; ctx.switchToModeId = ""; Serial.println("[VOICE] Short press -> single voice turn"); @@ -1644,6 +1686,12 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi) { ledFeedback("downloading"); String renderedModeId; bool fetched = fetchBMP(nextMode, nullptr, &renderedModeId); + if (fetched && renderedModeId.length() > 0) { + ctx.currentRenderedModeId = renderedModeId; + if (!ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID)) { + ctx.vocabReviewBackSide = false; + } + } bool aiChatRequested = renderedModeId.equalsIgnoreCase(AI_CHAT_MODE_ID); String pendingMode; if (!aiChatRequested) { @@ -1827,8 +1875,18 @@ static void checkAiChatButton() { ctx.aiBtnPressStart = millis(); } else if (!ctx.wantEnterAiChatMode && (millis() - ctx.aiBtnPressStart >= (unsigned long)AI_CHAT_BTN_HOLD_MS)) { +#if VOCAB_REVIEW_BUILD + if (ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID) && ctx.vocabReviewBackSide) { + Serial.printf("[VOCAB] Switch held for %dms, submit rating\n", AI_CHAT_BTN_HOLD_MS); + ctx.wantVocabSubmitRating = true; + } else { + Serial.printf("[VOCAB] Switch held for %dms, enter vocab review\n", AI_CHAT_BTN_HOLD_MS); + ctx.wantEnterVocabReview = true; + } +#else Serial.printf("[AI CHAT] Switch held for %dms, queue enter ai chat\n", AI_CHAT_BTN_HOLD_MS); ctx.wantEnterAiChatMode = true; +#endif ctx.aiBtnPressStart = 0; } } else { @@ -1836,8 +1894,20 @@ static void checkAiChatButton() { unsigned long duration = millis() - ctx.aiBtnPressStart; if (duration >= (unsigned long)SHORT_PRESS_MIN_MS && duration < (unsigned long)AI_CHAT_BTN_HOLD_MS) { +#if VOCAB_REVIEW_BUILD + if (ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID)) { + if (ctx.vocabReviewBackSide) { + Serial.printf("[VOCAB] Short press %lums, next rating\n", duration); + ctx.wantVocabNextRating = true; + } else { + Serial.printf("[VOCAB] Short press %lums, flip card\n", duration); + ctx.wantVocabFlip = true; + } + } +#else Serial.printf("[VOICE] Short press %lums, queue single voice turn\n", duration); ctx.wantSingleVoiceTurn = true; +#endif } } ctx.aiBtnPressStart = 0; diff --git a/firmware/src/network.cpp b/firmware/src/network.cpp index b5b76670..5661ec92 100644 --- a/firmware/src/network.cpp +++ b/firmware/src/network.cpp @@ -933,6 +933,46 @@ bool postRuntimeMode(const char *mode) { return false; } +bool postVocabEvent(const char *action, const char *rating) { + if (!ensureDeviceToken()) return false; + String mac = WiFi.macAddress(); + String url = cfgServer + "/api/device/" + mac + "/vocab/event"; + bool useSSL = cfgServer.startsWith("https://"); + String body = String("{\"action\":\"") + (action ? action : "") + "\""; + if (rating && strlen(rating) > 0) { + body += String(",\"rating\":\"") + rating + "\""; + } + body += "}"; + + for (int attempt = 0; attempt < 2; attempt++) { + WiFiClient plainClient; + WiFiClientSecure secClient; + HTTPClient http; + if (useSSL) { + secClient.setCACert(ROOT_CA); + http.begin(secClient, url); + } else { + http.begin(plainClient, url); + } + http.addHeader("Content-Type", "application/json"); + http.setTimeout(HTTP_TIMEOUT); + if (cfgDeviceToken.length() > 0) { + http.addHeader("X-Device-Token", cfgDeviceToken); + } + + int code = http.POST(body); + http.end(); + Serial.printf("[VOCAB] POST %s -> %d\n", action ? action : "", code); + if (code >= 200 && code < 300) { + return true; + } + if (!recoverDeviceTokenIfUnauthorized(code)) { + return false; + } + } + return false; +} + static bool parseVoiceTurnResponse(const String &body, String &turnId, String &replyText, String &transcript, bool &exitConversation) { turnId = extractJsonStringField(body, "turn_id"); replyText = extractJsonStringField(body, "reply_text"); diff --git a/firmware/src/network.h b/firmware/src/network.h index f2dfac81..78a146a9 100644 --- a/firmware/src/network.h +++ b/firmware/src/network.h @@ -59,6 +59,7 @@ bool peekPendingMode(String &pendingModeOut); // POST runtime mode (active/interval) to backend. bool postRuntimeMode(const char *mode); +bool postVocabEvent(const char *action, const char *rating = nullptr); // POST device config JSON to backend /api/config endpoint. void postConfigToBackend(); From d763a6f12288d69005efd555337d2241fb89aeff Mon Sep 17 00:00:00 2001 From: AeBoPi <109503402+AeBoPi@users.noreply.github.com> Date: Tue, 26 May 2026 21:15:31 +0800 Subject: [PATCH 3/8] feat: add vocab audio and deck importer --- .gitignore | 9 +- backend/api/routes/device.py | 27 +++- backend/core/modes/builtin/vocab_review.json | 60 +++++---- backend/core/vocab_store.py | 61 ++++----- backend/scripts/import_kylebing_vocab.py | 128 +++++++++++++++++++ docs/deploy.md | 1 + docs/vocab-review.md | 27 +++- firmware/src/main.cpp | 45 +++++++ firmware/src/network.cpp | 58 +++++++++ firmware/src/network.h | 2 + 10 files changed, 358 insertions(+), 60 deletions(-) create mode 100644 backend/scripts/import_kylebing_vocab.py diff --git a/.gitignore b/.gitignore index d90fcd5d..af71aa4d 100644 --- a/.gitignore +++ b/.gitignore @@ -258,6 +258,13 @@ backend/md/ backend/fonts/bitmap/ backend/fonts/truetype/ backend/scripts/generate_invite_codes.py +backend/core/vocab_data/primary_en.json +backend/core/vocab_data/middle_school_en.json +backend/core/vocab_data/high_school_en.json +backend/core/vocab_data/cet4_en.json +backend/core/vocab_data/cet6_en.json +backend/core/vocab_data/ielts_en.json +backend/core/vocab_data/toefl_en.json backend/core/modes/custom/ tmp/* paper/* @@ -268,4 +275,4 @@ docs/mobile-app-design.md inksight_tech/ lab/ -.cursor/ \ No newline at end of file +.cursor/ diff --git a/backend/api/routes/device.py b/backend/api/routes/device.py index 59772b79..8088cb09 100644 --- a/backend/api/routes/device.py +++ b/backend/api/routes/device.py @@ -35,7 +35,7 @@ update_device_state, validate_alert_token, ) -from core.vocab_store import VOCAB_MODE_ID, handle_vocab_event +from core.vocab_store import VOCAB_MODE_ID, get_vocab_content, handle_vocab_event from core.patterns.utils import apply_text_fontmode, load_font from core.renderer import image_to_bmp_bytes, image_to_png_bytes from core.schemas import DeviceHeartbeatRequest, OkResponse @@ -163,6 +163,31 @@ async def vocab_review_event( return result +@router.get("/device/{mac}/vocab/audio") +async def get_vocab_review_audio( + mac: str, + x_device_token: Optional[str] = Header(default=None), +): + mac = validate_mac_param(mac) + await require_device_token(mac, x_device_token) + cfg = await get_active_config(mac, log_load=False) + content = await get_vocab_content(mac, cfg) + word = str(content.get("word") or "").strip() + if not word or content.get("state") == "empty": + return Response(status_code=204) + + try: + from api.routes.voice import _resolve_device_voice_runtime_settings + from core.voice_service import synthesize_prompt_pcm + + settings = await _resolve_device_voice_runtime_settings(mac) + audio_pcm = await synthesize_prompt_pcm(word, settings=settings) + except Exception as exc: + logger.exception("[VOCAB] TTS failed for mac=%s word=%s", mac, word) + return JSONResponse({"error": str(exc)}, status_code=500) + return Response(content=audio_pcm, media_type="application/octet-stream") + + @router.post("/device/{mac}/heartbeat", response_model=OkResponse) async def post_device_heartbeat( mac: str, diff --git a/backend/core/modes/builtin/vocab_review.json b/backend/core/modes/builtin/vocab_review.json index 27972392..162743b1 100644 --- a/backend/core/modes/builtin/vocab_review.json +++ b/backend/core/modes/builtin/vocab_review.json @@ -5,9 +5,18 @@ "cacheable": false, "description": "单按键间隔重复背词卡片", "settings_schema": [ - {"key": "deck_id", "label": "词库", "type": "select", "default": "core_en", "options": [{"label": "核心英语", "value": "core_en"}]}, - {"key": "daily_limit", "label": "每日上限", "type": "number", "default": 30, "min": 1, "max": 200}, - {"key": "new_cards_per_day", "label": "每日新词", "type": "number", "default": 10, "min": 0, "max": 100} + {"key": "deck_id", "label": "词库", "type": "select", "default": "primary_en", "options": [ + {"label": "小学英语", "value": "primary_en"}, + {"label": "初中英语", "value": "middle_school_en"}, + {"label": "高中英语", "value": "high_school_en"}, + {"label": "四级词汇", "value": "cet4_en"}, + {"label": "六级词汇", "value": "cet6_en"}, + {"label": "雅思词汇", "value": "ielts_en"}, + {"label": "托福词汇", "value": "toefl_en"}, + {"label": "核心英语", "value": "core_en"} + ]}, + {"key": "daily_limit", "label": "每日完成个数", "type": "number", "default": 30, "min": 1, "max": 200}, + {"key": "new_cards_per_day", "label": "每日新词数", "type": "number", "default": 10, "min": 0, "max": 100} ], "content": { "type": "computed", @@ -26,10 +35,11 @@ "layout": { "status_bar": {"line_width": 1, "dashed": false}, "body": [ - {"type": "text", "template": "VOCAB {progress}", "font": "noto_serif_regular", "font_size": 12, "align": "center", "margin_x": 20, "max_lines": 1}, - {"type": "spacer", "height": 12}, - {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 42, "align": "center", "margin_x": 18, "max_lines": 1}, - {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_size": 16, "align": "center", "margin_x": 24, "max_lines": 1}, + {"type": "spacer", "height": 22}, + {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 52, "align": "center", "margin_x": 38, "max_lines": 1}, + {"type": "spacer", "height": 8}, + {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 13, "align": "center", "margin_x": 24, "max_lines": 1}, + {"type": "spacer", "height": 8}, { "type": "conditional", "field": "state", @@ -38,11 +48,11 @@ "op": "eq", "value": "back", "children": [ - {"type": "separator", "style": "short", "width": 70, "line_width": 1}, - {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 18, "align": "center", "margin_x": 32, "max_lines": 2}, + {"type": "separator", "style": "short", "width": 40, "line_width": 1}, + {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 16, "align": "center", "margin_x": 32, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 12, "align": "center", "margin_x": 38, "max_lines": 2}, - {"type": "spacer", "height": 6}, - {"type": "text", "template": "评分:{rating_label}", "font": "noto_serif_bold", "font_size": 18, "align": "center", "margin_x": 20, "max_lines": 1}, + {"type": "spacer", "height": 4}, + {"type": "text", "template": "评分:{rating_label}", "font": "noto_serif_bold", "font_size": 16, "align": "center", "margin_x": 20, "max_lines": 1}, {"type": "text", "field": "rating_hint", "font": "noto_serif_light", "font_size": 10, "align": "center", "margin_x": 20, "max_lines": 1} ] }, @@ -57,30 +67,32 @@ } ], "fallback_children": [ - {"type": "spacer", "height": 28}, - {"type": "text", "template": "短按翻面", "font": "noto_serif_light", "font_size": 14, "align": "center", "margin_x": 20, "max_lines": 1} + {"type": "spacer", "height": 42} ] } ], - "footer": {"label": "VOCAB"} + "footer": {"label": "VOCAB", "attribution_template": "进度 {progress}"} }, "layout_overrides": { "296x128": { "body": [ - {"type": "text", "template": "VOCAB {progress}", "font": "noto_serif_regular", "font_size": 9, "align": "center", "margin_x": 8, "max_lines": 1}, - {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 25, "align": "center", "margin_x": 8, "max_lines": 1}, - {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_size": 10, "align": "center", "margin_x": 8, "max_lines": 1}, - {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 12, "align": "center", "margin_x": 10, "max_lines": 1}, {"type": "text", "template": "{rating_label}", "font": "noto_serif_bold", "font_size": 13, "align": "center", "margin_x": 8, "max_lines": 1}]}], "fallback_children": [{"type": "text", "template": "短按翻面", "font": "noto_serif_light", "font_size": 10, "align": "center", "margin_x": 8, "max_lines": 1}]} + {"type": "spacer", "height": 4}, + {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 22, "align": "center", "margin_x": 8, "max_lines": 1}, + {"type": "spacer", "height": 4}, + {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 16, "align": "center", "margin_x": 8, "max_lines": 1}, + {"type": "spacer", "height": 2}, + {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 30, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 12, "align": "center", "margin_x": 10, "max_lines": 1}, {"type": "text", "template": "{rating_label}", "font": "noto_serif_bold", "font_size": 13, "align": "center", "margin_x": 8, "max_lines": 1}]}], "fallback_children": [{"type": "spacer", "height": 8}]} ], - "footer": {"label": "VOCAB", "height": 18} + "footer": {"label": "VOCAB", "height": 18, "attribution_template": "{progress}"} }, "648x480": { "body": [ - {"type": "text", "template": "VOCAB {progress}", "font": "noto_serif_regular", "font_size": 16, "align": "center", "margin_x": 24, "max_lines": 1}, - {"type": "spacer", "height": 22}, - {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 62, "align": "center", "margin_x": 24, "max_lines": 1}, - {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_size": 22, "align": "center", "margin_x": 30, "max_lines": 1}, - {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 90, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 28, "align": "center", "margin_x": 52, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 18, "align": "center", "margin_x": 70, "max_lines": 2}, {"type": "text", "template": "评分:{rating_label}", "font": "noto_serif_bold", "font_size": 28, "align": "center", "margin_x": 30, "max_lines": 1}]}], "fallback_children": [{"type": "spacer", "height": 44}, {"type": "text", "template": "短按翻面", "font": "noto_serif_light", "font_size": 20, "align": "center", "margin_x": 30, "max_lines": 1}]} + {"type": "spacer", "height": 42}, + {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 64, "align": "center", "margin_x": 32, "max_lines": 1}, + {"type": "spacer", "height": 10}, + {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 14, "align": "center", "margin_x": 30, "max_lines": 1}, + {"type": "spacer", "height": 10}, + {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 44, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 20, "align": "center", "margin_x": 52, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 14, "align": "center", "margin_x": 70, "max_lines": 2}, {"type": "text", "template": "评分:{rating_label}", "font": "noto_serif_bold", "font_size": 22, "align": "center", "margin_x": 30, "max_lines": 1}]}], "fallback_children": [{"type": "spacer", "height": 44}]} ] } } diff --git a/backend/core/vocab_store.py b/backend/core/vocab_store.py index a8036834..96af6d81 100644 --- a/backend/core/vocab_store.py +++ b/backend/core/vocab_store.py @@ -8,7 +8,7 @@ from .db import get_main_db VOCAB_MODE_ID = "VOCAB_REVIEW" -DEFAULT_DECK_ID = "core_en" +DEFAULT_DECK_ID = "primary_en" DEFAULT_DAILY_LIMIT = 30 DEFAULT_NEW_CARDS_PER_DAY = 10 RATINGS = ("forgot", "fuzzy", "remember") @@ -18,44 +18,45 @@ "remember": "记住", } -_DATA_PATH = Path(__file__).resolve().parent / "vocab_data" / "core_en.json" +_DATA_DIR = Path(__file__).resolve().parent / "vocab_data" async def seed_builtin_vocab() -> None: - if not _DATA_PATH.exists(): - return - try: - items = json.loads(_DATA_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return - if not isinstance(items, list): + if not _DATA_DIR.exists(): return now = datetime.now().isoformat() db = await get_main_db() - for item in items: - if not isinstance(item, dict): + for path in sorted(_DATA_DIR.glob("*.json")): + try: + items = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): continue - word = str(item.get("word") or "").strip() - definition = str(item.get("definition") or "").strip() - if not word or not definition: + if not isinstance(items, list): continue - await db.execute( - """ - INSERT OR IGNORE INTO vocab_items - (deck_id, word, phonetic, definition, example, difficulty, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - str(item.get("deck_id") or DEFAULT_DECK_ID), - word, - str(item.get("phonetic") or ""), - definition, - str(item.get("example") or ""), - int(item.get("difficulty") or 1), - now, - ), - ) + for item in items: + if not isinstance(item, dict): + continue + word = str(item.get("word") or "").strip() + definition = str(item.get("definition") or "").strip() + if not word or not definition: + continue + await db.execute( + """ + INSERT OR IGNORE INTO vocab_items + (deck_id, word, phonetic, definition, example, difficulty, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + str(item.get("deck_id") or DEFAULT_DECK_ID), + word, + str(item.get("phonetic") or ""), + definition, + str(item.get("example") or ""), + int(item.get("difficulty") or 1), + now, + ), + ) await db.commit() diff --git a/backend/scripts/import_kylebing_vocab.py b/backend/scripts/import_kylebing_vocab.py new file mode 100644 index 00000000..3c587ef2 --- /dev/null +++ b/backend/scripts/import_kylebing_vocab.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +import re +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +RAW_BASE = "https://raw.githubusercontent.com/KyleBing/english-vocabulary/master/json_original/json-sentence" +OUT_DIR = Path(__file__).resolve().parents[1] / "core" / "vocab_data" + +DECKS: dict[str, dict[str, Any]] = { + "primary_en": { + "difficulty": 1, + "sources": [ + "PEPXiaoXue3_1.json", + "PEPXiaoXue3_2.json", + "PEPXiaoXue4_1.json", + "PEPXiaoXue4_2.json", + "PEPXiaoXue5_1.json", + "PEPXiaoXue5_2.json", + "PEPXiaoXue6_1.json", + "PEPXiaoXue6_2.json", + ], + }, + "middle_school_en": {"difficulty": 2, "sources": ["ChuZhong_2.json", "ChuZhong_3.json"]}, + "high_school_en": {"difficulty": 3, "sources": ["GaoZhong_2.json", "GaoZhong_3.json"]}, + "cet4_en": {"difficulty": 4, "sources": ["CET4_1.json", "CET4_2.json", "CET4_3.json"]}, + "cet6_en": {"difficulty": 5, "sources": ["CET6_1.json", "CET6_2.json", "CET6_3.json"]}, + "ielts_en": {"difficulty": 6, "sources": ["IELTS_2.json", "IELTS_3.json"]}, + "toefl_en": {"difficulty": 7, "sources": ["TOEFL_2.json", "TOEFL_3.json"]}, +} + + +def fetch_source(name: str) -> list[dict[str, Any]]: + url = f"{RAW_BASE}/{urllib.parse.quote(name)}" + with urllib.request.urlopen(url, timeout=60) as response: + payload = response.read().decode("utf-8") + data = json.loads(payload) + if not isinstance(data, list): + raise ValueError(f"{name}: expected list, got {type(data).__name__}") + return [item for item in data if isinstance(item, dict)] + + +def clean_text(value: Any) -> str: + text = str(value or "").strip() + text = re.sub(r"\s+", " ", text) + return text + + +def normalize_phonetic(value: Any) -> str: + text = clean_text(value).strip("/") + return f"/{text}/" if text else "" + + +def pick_definition(item: dict[str, Any]) -> str: + translations = item.get("translations") + parts: list[str] = [] + if isinstance(translations, list): + for translation in translations[:3]: + if not isinstance(translation, dict): + continue + body = clean_text(translation.get("translation")) + pos = clean_text(translation.get("type")) + if not body: + continue + parts.append(f"{pos}. {body}" if pos else body) + return ";".join(parts) + + +def pick_example(item: dict[str, Any]) -> str: + sentences = item.get("sentences") + if isinstance(sentences, list): + for sentence in sentences: + if not isinstance(sentence, dict): + continue + text = clean_text(sentence.get("sentence")) + if text: + return text + return "" + + +def convert_item(deck_id: str, difficulty: int, item: dict[str, Any]) -> dict[str, Any] | None: + word = clean_text(item.get("word")) + definition = pick_definition(item) + if not word or not definition: + return None + phonetic = normalize_phonetic(item.get("us") or item.get("uk")) + return { + "deck_id": deck_id, + "word": word, + "phonetic": phonetic, + "definition": definition, + "example": pick_example(item), + "difficulty": difficulty, + } + + +def build_deck(deck_id: str, spec: dict[str, Any]) -> list[dict[str, Any]]: + seen: set[str] = set() + output: list[dict[str, Any]] = [] + difficulty = int(spec["difficulty"]) + for source in spec["sources"]: + for raw_item in fetch_source(source): + item = convert_item(deck_id, difficulty, raw_item) + if item is None: + continue + key = item["word"].casefold() + if key in seen: + continue + seen.add(key) + output.append(item) + return output + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + for deck_id, spec in DECKS.items(): + items = build_deck(deck_id, spec) + path = OUT_DIR / f"{deck_id}.json" + path.write_text(json.dumps(items, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"{deck_id}: {len(items)} -> {path}") + + +if __name__ == "__main__": + main() diff --git a/docs/deploy.md b/docs/deploy.md index 6d1f2891..7244619d 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -43,6 +43,7 @@ cd backend pip install -r requirements.txt python scripts/setup_fonts.py +python scripts/import_kylebing_vocab.py cp .env.example .env # 按需填写环境变量 diff --git a/docs/vocab-review.md b/docs/vocab-review.md index 01e4ae12..5b154f80 100644 --- a/docs/vocab-review.md +++ b/docs/vocab-review.md @@ -1,6 +1,25 @@ # 背单词模式 -`VOCAB_REVIEW` 是内置背词模式,使用设备 MAC 独立保存进度。第一版使用项目内置 `core_en` 基础词库,不包含语音读词或自动判答。 +`VOCAB_REVIEW` 是内置背词模式,使用设备 MAC 独立保存进度。词库数据在部署时由脚本从 KyleBing/english-vocabulary 的 `json-sentence` 数据生成,不直接提交大 JSON 文件。 + +## 词库准备 + +首次部署或清理本地生成文件后,先运行: + +```bash +cd backend +python scripts/import_kylebing_vocab.py +``` + +脚本会生成以下本地词库文件: + +- `primary_en`:小学英语 +- `middle_school_en`:初中英语 +- `high_school_en`:高中英语 +- `cet4_en`:四级词汇 +- `cet6_en`:六级词汇 +- `ielts_en`:雅思词汇 +- `toefl_en`:托福词汇 ## 固件环境 @@ -27,9 +46,9 @@ platformio run -e epd_42_wroom32e_vocab_review ## 模式设置 -- `deck_id`:词库 ID,默认 `core_en`。 -- `daily_limit`:每日复习总上限,默认 `30`。 -- `new_cards_per_day`:每日新词上限,默认 `10`。 +- `deck_id`:词库 ID,默认 `primary_en`。 +- `daily_limit`:每日完成个数,默认 `30`。 +- `new_cards_per_day`:每日新词数,默认 `10`。 ## 设备 API diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 2c964d16..31b0723a 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -229,6 +229,46 @@ static void drainSendQueue(AudioService &as) { as.ReleaseSendPacket(pkt); } } + +static void vocabAudioChunkCallback(const uint8_t *data, size_t len, void *userData) { + AudioService *audioService = static_cast(userData); + if (audioService) { + audioService->PushPcmForPlayback(data, len, 1); + } +} + +static void playCurrentVocabWordAudio() { +#if VOCAB_REVIEW_BUILD + Serial.println("[VOCAB] Playing current word audio..."); + static Inmp441Max98357Codec codec(false); + if (!codec.Start()) { + Serial.println("[VOCAB] codec start failed"); + return; + } + + static AudioService audioService; + if (!audioService.Initialize(&codec)) { + Serial.println("[VOCAB] AudioService init failed"); + codec.Stop(); + return; + } + audioService.Start(); + + bool ok = fetchVocabAudio(vocabAudioChunkCallback, &audioService); + if (!ok) { + Serial.println("[VOCAB] audio fetch failed"); + } + + unsigned long drainStart = millis(); + while (!audioService.IsPlaybackEmpty() && millis() - drainStart < 5000) { + delay(20); + } + delay(80); + audioService.Stop(); + audioService.ResetPlayback(); + codec.Stop(); +#endif +} #endif // ═════════════════════════════════════════════════════════════ @@ -906,6 +946,11 @@ void loop() { } lastContentChecksum = 0; triggerImmediateRefresh(false, true); + if (strcmp(action, "enter") == 0 || strcmp(action, "submit_rating") == 0) { +#if defined(BOARD_HAS_AUDIO) + playCurrentVocabWordAudio(); +#endif + } WiFi.disconnect(true); WiFi.mode(WIFI_OFF); ctx.setupDoneAt = millis(); diff --git a/firmware/src/network.cpp b/firmware/src/network.cpp index 5661ec92..5a6fa59f 100644 --- a/firmware/src/network.cpp +++ b/firmware/src/network.cpp @@ -973,6 +973,64 @@ bool postVocabEvent(const char *action, const char *rating) { return false; } +bool fetchVocabAudio(AudioChunkCallback onChunk, void *userData) { + if (!onChunk) return false; + if (!ensureDeviceToken()) return false; + String mac = WiFi.macAddress(); + String url = cfgServer + "/api/device/" + mac + "/vocab/audio"; + bool useSSL = cfgServer.startsWith("https://"); + for (int attempt = 0; attempt < 2; attempt++) { + WiFiClient plainClient; + WiFiClientSecure secClient; + HTTPClient http; + if (useSSL) { + secClient.setCACert(ROOT_CA); + http.begin(secClient, url); + } else { + http.begin(plainClient, url); + } + http.setTimeout(60000); + if (cfgDeviceToken.length() > 0) { + http.addHeader("X-Device-Token", cfgDeviceToken); + } + + int code = http.GET(); + if (code == 204) { + Serial.println("[VOCAB] audio -> 204 no content"); + http.end(); + return true; + } + if (code == 200) { + WiFiClient *stream = http.getStreamPtr(); + uint8_t buffer[1024]; + while (http.connected() || stream->available()) { + int available = stream->available(); + if (available <= 0) { + delay(1); + continue; + } + int readLen = stream->readBytes(buffer, min(available, (int)sizeof(buffer))); + if (readLen > 0) { + onChunk(buffer, (size_t)readLen, userData); + } + } + http.end(); + return true; + } + if (code < 0) { + Serial.printf("[VOCAB] audio error: %s\n", http.errorToString(code).c_str()); + } else { + String body = http.getString(); + Serial.printf("[VOCAB] audio -> %d %s\n", code, body.substring(0, 200).c_str()); + } + http.end(); + if (!recoverDeviceTokenIfUnauthorized(code)) { + return false; + } + } + return false; +} + static bool parseVoiceTurnResponse(const String &body, String &turnId, String &replyText, String &transcript, bool &exitConversation) { turnId = extractJsonStringField(body, "turn_id"); replyText = extractJsonStringField(body, "reply_text"); diff --git a/firmware/src/network.h b/firmware/src/network.h index 78a146a9..933d1cfc 100644 --- a/firmware/src/network.h +++ b/firmware/src/network.h @@ -60,6 +60,8 @@ bool peekPendingMode(String &pendingModeOut); // POST runtime mode (active/interval) to backend. bool postRuntimeMode(const char *mode); bool postVocabEvent(const char *action, const char *rating = nullptr); +typedef void (*AudioChunkCallback)(const uint8_t *data, size_t len, void *userData); +bool fetchVocabAudio(AudioChunkCallback onChunk, void *userData = nullptr); // POST device config JSON to backend /api/config endpoint. void postConfigToBackend(); From e786bfd1c06d428b0a25497e48a70a26538aa7b5 Mon Sep 17 00:00:00 2001 From: AeBoPi <109503402+AeBoPi@users.noreply.github.com> Date: Wed, 27 May 2026 13:08:43 +0800 Subject: [PATCH 4/8] Optimize vocab review local rating refresh --- backend/api/routes/device.py | 118 ++++++++- backend/core/json_renderer.py | 73 +++++- backend/core/modes/builtin/vocab_review.json | 26 +- backend/core/vocab_store.py | 10 + firmware/platformio.ini | 14 ++ firmware/src/config.h | 2 + firmware/src/display.cpp | 44 ++++ firmware/src/display.h | 3 + firmware/src/epd_driver.cpp | 62 ++++- firmware/src/epd_driver.h | 2 + firmware/src/main.cpp | 237 ++++++++++++++++--- firmware/src/network.cpp | 96 ++++++++ firmware/src/network.h | 1 + 13 files changed, 635 insertions(+), 53 deletions(-) diff --git a/backend/api/routes/device.py b/backend/api/routes/device.py index 8088cb09..25ebd5ee 100644 --- a/backend/api/routes/device.py +++ b/backend/api/routes/device.py @@ -4,6 +4,7 @@ import io import json import os +import struct from datetime import datetime, timedelta from typing import Optional @@ -23,6 +24,7 @@ ) from core.auth import require_admin, require_device_token, require_user, validate_mac_param from core.config import SCREEN_HEIGHT, SCREEN_WIDTH +from core.context import calc_battery_pct, extract_location_settings, get_date_context, get_weather from core.config_store import ( consume_claim_token, create_claim_token, @@ -159,7 +161,7 @@ async def vocab_review_event( result = await handle_vocab_event(mac, action, cfg, rating=rating) if not result.get("ok"): return JSONResponse({"error": result.get("error") or "invalid_action"}, status_code=400) - await update_device_state(mac, pending_refresh=1) + await update_device_state(mac, pending_mode=VOCAB_MODE_ID, pending_refresh=1) return result @@ -188,6 +190,120 @@ async def get_vocab_review_audio( return Response(content=audio_pcm, media_type="application/octet-stream") +def _image_to_mono_bytes(img: Image.Image, w: int, h: int) -> bytes: + if img.mode != "1": + img = img.convert("1") + if img.size != (w, h): + img = img.resize((w, h)).convert("1") + return img.tobytes() + + +def _slice_mono_region(raw: bytes, w: int, y_start: int, y_end: int) -> bytes: + row_bytes = w // 8 + return raw[y_start * row_bytes:y_end * row_bytes] + + +@router.get("/device/{mac}/vocab/review-pack") +async def get_vocab_review_pack( + mac: str, + w: int = Query(default=SCREEN_WIDTH, ge=100, le=1600), + h: int = Query(default=SCREEN_HEIGHT, ge=100, le=1200), + y_start: Optional[int] = Query(default=None, ge=0), + y_end: Optional[int] = Query(default=None, ge=1), + v: float = Query(default=3.7), + x_device_token: Optional[str] = Header(default=None), +): + """Return one vocab card pack: front full image + 3 back-side rating regions. + + Binary format (little endian): + - 4 bytes magic: IVP1 + - uint16 w, h, y_start, y_end + - uint32 full_len, part_len + - uint8 rating_count, current_cursor, reserved, reserved + - front full mono bytes + - rating_count partial mono regions, cursor order: forgot, fuzzy, remember + """ + mac = validate_mac_param(mac) + await require_device_token(mac, x_device_token) + if w % 8 != 0: + return JSONResponse({"error": "w must be divisible by 8"}, status_code=400) + + ys = y_start if y_start is not None else ((h * 54 // 100) if h <= 128 else (h * 52 // 100)) + ye = y_end if y_end is not None else h - max(18, h // 12) + ys = max(0, min(h, int(ys))) + ye = max(0, min(h, int(ye))) + if ye <= ys: + return JSONResponse({"error": "invalid region"}, status_code=400) + + cfg = await get_active_config(mac, log_load=False) + content = await get_vocab_content(mac, cfg) + + from core.mode_registry import get_registry + from core.json_renderer import render_json_mode + + registry = get_registry() + jm = registry.get_json_mode(VOCAB_MODE_ID, mac, language=(cfg or {}).get("mode_language") or "zh") + if not jm: + return JSONResponse({"error": "vocab mode not found"}, status_code=500) + + date_ctx = await get_date_context() + weather = await get_weather(**extract_location_settings(cfg or {})) + battery_pct = calc_battery_pct(v) + language = (cfg or {}).get("mode_language") or "zh" + + base = dict(content) + front_content = {**base, "state": "front", "rating_cursor": 0} + front_img = render_json_mode( + jm.definition, + front_content, + date_str=date_ctx["date_str"], + weather_str=weather["weather_str"], + battery_pct=battery_pct, + weather_code=weather.get("weather_code", -1), + time_str=date_ctx.get("time_str", ""), + screen_w=w, + screen_h=h, + colors=2, + language=language, + ) + front_raw = _image_to_mono_bytes(front_img, w, h) + + parts: list[bytes] = [] + for cursor in range(3): + back_content = {**base, "state": "back", "rating_cursor": cursor} + back_img = render_json_mode( + jm.definition, + back_content, + date_str=date_ctx["date_str"], + weather_str=weather["weather_str"], + battery_pct=battery_pct, + weather_code=weather.get("weather_code", -1), + time_str=date_ctx.get("time_str", ""), + screen_w=w, + screen_h=h, + colors=2, + language=language, + ) + parts.append(_slice_mono_region(_image_to_mono_bytes(back_img, w, h), w, ys, ye)) + + part_len = len(parts[0]) if parts else 0 + body = bytearray() + body += b"IVP1" + body += struct.pack(" None: return font_size = int(block.get("font_size", 14) * ctx.scale) + font_name = block.get("font_name") font_key = block.get("font", "noto_serif_regular") - if has_cjk(text): + if font_name and not has_cjk(text): + font = load_font_by_name(font_name, font_size) + elif has_cjk(text): font_key = _pick_cjk_font(font_key) - font = load_font(font_key, font_size) + font = load_font(font_key, font_size) + else: + font = load_font(font_key, font_size) align = block.get("align", "center") margin_x = block.get("margin_x") @@ -1530,7 +1535,16 @@ def _render_text(ctx: RenderContext, block: dict) -> None: ctx.draw.text((x, line_y), line, fill=ctx.resolve_color(block), font=font) rendered_lines += 1 if rendered_lines: - ctx.y = start_y + (rendered_lines - 1) * line_height + last_line_h + used_h = (rendered_lines - 1) * line_height + last_line_h + if block.get("reserve_line_height"): + used_h = max(used_h, rendered_lines * line_height) + min_height = block.get("min_height") + if min_height is not None: + used_h = max(used_h, int(min_height * ctx.scale)) + ctx.y = start_y + used_h + margin_bottom = block.get("margin_bottom") + if margin_bottom is not None: + ctx.y += int(margin_bottom * ctx.scale) def _render_separator(ctx: RenderContext, block: dict) -> None: @@ -1740,6 +1754,58 @@ def _render_spacer(ctx: RenderContext, block: dict) -> None: ctx.y += max(0, int(round(h * ctx.scale))) +def _render_rating_choices(ctx: RenderContext, block: dict) -> None: + labels = block.get("labels") or ["忘了", "模糊", "记住"] + if not isinstance(labels, list) or not labels: + return + + try: + selected = int(ctx.get_field(block.get("selected_field", "rating_cursor")) or 0) + except (TypeError, ValueError): + selected = 0 + selected %= len(labels) + + font_size = int(block.get("font_size", 14) * ctx.scale) + font_key = block.get("font", "noto_serif_regular") + if any(has_cjk(str(label)) for label in labels): + font_key = _pick_cjk_font(font_key) + font = load_font(font_key, font_size) + + margin_x = int(block.get("margin_x", 26) * ctx.scale) + gap = int(block.get("gap", 8) * ctx.scale) + height = int(block.get("height", 24) * ctx.scale) + outline_width = max(1, int(block.get("line_width", 1) * ctx.scale)) + margin_bottom = int(block.get("margin_bottom", 6) * ctx.scale) + + count = len(labels) + total_w = max(20, ctx.available_width - margin_x * 2) + chip_w = max(12, (total_w - gap * (count - 1)) // count) + y = ctx.y + x = ctx.x_offset + margin_x + + for i, raw_label in enumerate(labels): + label = str(raw_label) + x0 = x + i * (chip_w + gap) + x1 = x0 + chip_w + y1 = y + height + is_selected = i == selected + if is_selected: + ctx.draw.rectangle([x0, y, x1, y1], fill=EINK_FG) + text_fill = EINK_BG + else: + ctx.draw.rectangle([x0, y, x1, y1], outline=EINK_FG, width=outline_width) + text_fill = EINK_FG + + bbox = font.getbbox(label) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] + tx = x0 + (chip_w - text_w) // 2 - bbox[0] + ty = y + (height - text_h) // 2 - bbox[1] + ctx.draw.text((tx, ty), label, fill=text_fill, font=font) + + ctx.y = y + height + margin_bottom + + def _render_icon_text(ctx: RenderContext, block: dict) -> None: icon_name = block.get("icon") field_name = block.get("field") @@ -3019,6 +3085,7 @@ def _render_timetable_weekly(ctx: RenderContext, block: dict) -> None: _BLOCK_RENDERERS["vertical_stack"] = _render_vertical_stack _BLOCK_RENDERERS["conditional"] = _render_conditional _BLOCK_RENDERERS["spacer"] = _render_spacer +_BLOCK_RENDERERS["rating_choices"] = _render_rating_choices _BLOCK_RENDERERS["icon_text"] = _render_icon_text _BLOCK_RENDERERS["weather_icon_text"] = _render_weather_icon_text _BLOCK_RENDERERS["two_column"] = _render_two_column diff --git a/backend/core/modes/builtin/vocab_review.json b/backend/core/modes/builtin/vocab_review.json index 162743b1..b54da3c9 100644 --- a/backend/core/modes/builtin/vocab_review.json +++ b/backend/core/modes/builtin/vocab_review.json @@ -29,16 +29,18 @@ "example": "", "progress": "0/30", "rating_label": "", + "rating_cursor": 0, "rating_hint": "" } }, "layout": { + "body_align": "top", "status_bar": {"line_width": 1, "dashed": false}, "body": [ {"type": "spacer", "height": 22}, - {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 52, "align": "center", "margin_x": 38, "max_lines": 1}, - {"type": "spacer", "height": 8}, - {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 13, "align": "center", "margin_x": 24, "max_lines": 1}, + {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 52, "line_height": 62, "reserve_line_height": true, "align": "center", "margin_x": 38, "max_lines": 1}, + {"type": "spacer", "height": 10}, + {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 13, "line_height": 18, "reserve_line_height": true, "align": "center", "margin_x": 24, "max_lines": 1}, {"type": "spacer", "height": 8}, { "type": "conditional", @@ -52,7 +54,7 @@ {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 16, "align": "center", "margin_x": 32, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 12, "align": "center", "margin_x": 38, "max_lines": 2}, {"type": "spacer", "height": 4}, - {"type": "text", "template": "评分:{rating_label}", "font": "noto_serif_bold", "font_size": 16, "align": "center", "margin_x": 20, "max_lines": 1}, + {"type": "rating_choices", "selected_field": "rating_cursor", "labels": ["忘了", "模糊", "记住"], "font": "noto_serif_bold", "font_size": 14, "height": 26, "margin_x": 28, "gap": 10, "margin_bottom": 4}, {"type": "text", "field": "rating_hint", "font": "noto_serif_light", "font_size": 10, "align": "center", "margin_x": 20, "max_lines": 1} ] }, @@ -77,22 +79,22 @@ "296x128": { "body": [ {"type": "spacer", "height": 4}, - {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 22, "align": "center", "margin_x": 8, "max_lines": 1}, - {"type": "spacer", "height": 4}, - {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 16, "align": "center", "margin_x": 8, "max_lines": 1}, + {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 22, "line_height": 28, "reserve_line_height": true, "align": "center", "margin_x": 8, "max_lines": 1}, + {"type": "spacer", "height": 3}, + {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 13, "line_height": 16, "reserve_line_height": true, "align": "center", "margin_x": 8, "max_lines": 1}, {"type": "spacer", "height": 2}, - {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 30, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 12, "align": "center", "margin_x": 10, "max_lines": 1}, {"type": "text", "template": "{rating_label}", "font": "noto_serif_bold", "font_size": 13, "align": "center", "margin_x": 8, "max_lines": 1}]}], "fallback_children": [{"type": "spacer", "height": 8}]} + {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 30, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 12, "align": "center", "margin_x": 10, "max_lines": 1}, {"type": "rating_choices", "selected_field": "rating_cursor", "labels": ["忘", "糊", "记"], "font": "noto_serif_bold", "font_size": 10, "height": 16, "margin_x": 18, "gap": 5, "margin_bottom": 0}]}], "fallback_children": [{"type": "spacer", "height": 8}]} ], "footer": {"label": "VOCAB", "height": 18, "attribution_template": "{progress}"} }, "648x480": { "body": [ {"type": "spacer", "height": 42}, - {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 64, "align": "center", "margin_x": 32, "max_lines": 1}, - {"type": "spacer", "height": 10}, - {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 14, "align": "center", "margin_x": 30, "max_lines": 1}, + {"type": "text", "field": "word", "font": "noto_serif_bold", "font_size": 64, "line_height": 78, "reserve_line_height": true, "align": "center", "margin_x": 32, "max_lines": 1}, + {"type": "spacer", "height": 12}, + {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 14, "line_height": 20, "reserve_line_height": true, "align": "center", "margin_x": 30, "max_lines": 1}, {"type": "spacer", "height": 10}, - {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 44, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 20, "align": "center", "margin_x": 52, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 14, "align": "center", "margin_x": 70, "max_lines": 2}, {"type": "text", "template": "评分:{rating_label}", "font": "noto_serif_bold", "font_size": 22, "align": "center", "margin_x": 30, "max_lines": 1}]}], "fallback_children": [{"type": "spacer", "height": 44}]} + {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 44, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 20, "align": "center", "margin_x": 52, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 14, "align": "center", "margin_x": 70, "max_lines": 2}, {"type": "rating_choices", "selected_field": "rating_cursor", "labels": ["忘了", "模糊", "记住"], "font": "noto_serif_bold", "font_size": 20, "height": 34, "margin_x": 74, "gap": 16, "margin_bottom": 0}]}], "fallback_children": [{"type": "spacer", "height": 44}]} ] } } diff --git a/backend/core/vocab_store.py b/backend/core/vocab_store.py index 96af6d81..0ef6d8f3 100644 --- a/backend/core/vocab_store.py +++ b/backend/core/vocab_store.py @@ -161,6 +161,13 @@ async def handle_vocab_event(mac: str, action: str, config: dict[str, Any] | Non action = str(action or "").strip().lower() if action == "enter": await ensure_vocab_session(mac, config) + now = datetime.now().isoformat() + db = await get_main_db() + await db.execute( + "UPDATE vocab_session_state SET side = 'front', rating_cursor = 0, updated_at = ? WHERE mac = ?", + (now, mac), + ) + await db.commit() return {"ok": True, "action": action} session = await ensure_vocab_session(mac, config) @@ -201,9 +208,11 @@ async def get_vocab_content(mac: str, config: dict[str, Any] | None = None) -> d "example": "", "progress": f"{reviewed}/{daily_limit}", "rating_label": "", + "rating_cursor": 0, "rating_hint": "明天再来", } rating = RATINGS[int(session.get("rating_cursor") or 0) % len(RATINGS)] + rating_cursor = int(session.get("rating_cursor") or 0) % len(RATINGS) return { "state": str(session.get("side") or "front"), "word": item["word"], @@ -212,6 +221,7 @@ async def get_vocab_content(mac: str, config: dict[str, Any] | None = None) -> d "example": item.get("example") or "", "progress": f"{reviewed}/{daily_limit}", "rating_label": RATING_LABELS[rating], + "rating_cursor": rating_cursor, "rating_hint": "短按切换评分,长按提交", } diff --git a/firmware/platformio.ini b/firmware/platformio.ini index f932a946..b57f2246 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -162,6 +162,20 @@ build_flags = -DEPD_GXEPD2_SPI_HZ=2000000 -DALLOW_INSECURE_FALLBACK=0 +[env:epd_42_zhongjingyuan_bw_gxepd2_gye042a87_wroom32e_vocab_review] +extends = common +board = esp32dev +upload_speed = 460800 +build_flags = + -DBOARD_PROFILE_ESP32_WROOM32E + -DEPD_WIDTH=400 + -DEPD_HEIGHT=300 + -DEPD_PANEL_42_GXEPD2_GYE042A87 + -DEPD_GXEPD2_SPI_HZ=2000000 + -DALLOW_INSECURE_FALLBACK=0 + -DVOCAB_REVIEW_BUILD=1 + -DAUTO_BOOT_AI_CHAT=0 + [env:epd_42_zhongjingyuan_bw_gxepd2_gye042a87_smt_wroom32e] extends = common board = esp32dev diff --git a/firmware/src/config.h b/firmware/src/config.h index 8d0f1e07..e1635da2 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -111,6 +111,8 @@ static const int WIFI_TIMEOUT = 15000; // ms static const int HTTP_TIMEOUT = 30000; // ms static const int CFG_BTN_HOLD_MS = 2000; // Long press duration to trigger config mode static const int AI_CHAT_BTN_HOLD_MS = 3000; // Long press duration to enter AI chat mode +static const int VOCAB_ENTER_HOLD_MS = 2000; // Long press duration to enter vocab review +static const int VOCAB_BTN_HOLD_MS = 1500; // Long press duration to submit vocab rating static const int SHORT_PRESS_MIN_MS = 50; // Minimum short press duration (debounce) static const int LIVE_POLL_MS = 5000; // Poll interval for pending remote actions static const int LIVE_WIFI_RETRY_MS = 5000; // Retry interval when WiFi is disconnected diff --git a/firmware/src/display.cpp b/firmware/src/display.cpp index db4a3b4f..d54d9c9f 100644 --- a/firmware/src/display.cpp +++ b/firmware/src/display.cpp @@ -645,6 +645,50 @@ void updateTimeDisplay() { epdPartialDisplay(partBuf, TIME_RGN_X0, TIME_RGN_Y0, TIME_RGN_X1, TIME_RGN_Y1); } +// ── Vocab review partial refresh ──────────────────────────── + +void updateVocabRatingRegion(const uint8_t *oldImage) { + const int xStart = 0; + const int xEnd = W; + const int yStart = (H <= 128) ? (H * 54 / 100) : (H * 52 / 100); + const int yEnd = H - max(18, H / 12); + if (yEnd <= yStart) { + epdDisplayFast(imgBuf); + return; + } + + const int rowBytes = W / 8; + const int regionH = yEnd - yStart; + const int partLen = rowBytes * regionH; + uint8_t *partBuf = (uint8_t *)malloc(partLen); + uint8_t *oldPartBuf = oldImage ? (uint8_t *)malloc(partLen) : nullptr; + if (!partBuf || (oldImage && !oldPartBuf)) { + Serial.println("[VOCAB] Partial buffer alloc failed, using fast full refresh"); + if (partBuf) free(partBuf); + if (oldPartBuf) free(oldPartBuf); + epdDisplayFast(imgBuf); + return; + } + + for (int row = 0; row < regionH; row++) { + memcpy( + partBuf + row * rowBytes, + imgBuf + (yStart + row) * rowBytes, + rowBytes + ); + if (oldPartBuf) { + memcpy( + oldPartBuf + row * rowBytes, + oldImage + (yStart + row) * rowBytes, + rowBytes + ); + } + } + epdPartialDisplayWithOld(partBuf, oldPartBuf, xStart, yStart, xEnd, yEnd); + if (oldPartBuf) free(oldPartBuf); + free(partBuf); +} + // ── Mode preview screen (double-click transition) ─────────── void showModePreview(const char *modeName) { diff --git a/firmware/src/display.h b/firmware/src/display.h index 5b47eb90..915741bd 100644 --- a/firmware/src/display.h +++ b/firmware/src/display.h @@ -33,6 +33,9 @@ int currentPeriodIndex(); void updateTimeDisplay(); +// Refresh the reveal/control region from the current vocab review imgBuf. +void updateVocabRatingRegion(const uint8_t *oldImage = nullptr); + // Smart display: uses no-flash partial refresh normally, full refresh every N cycles void smartDisplay(const uint8_t *image); diff --git a/firmware/src/epd_driver.cpp b/firmware/src/epd_driver.cpp index 73fe8c71..e36f15ef 100644 --- a/firmware/src/epd_driver.cpp +++ b/firmware/src/epd_driver.cpp @@ -542,8 +542,21 @@ void epdDisplayFast(const uint8_t *image) { // ── EPD partial refresh ───────────────────────────────────── void epdPartialDisplay(uint8_t *data, int xStart, int yStart, int xEnd, int yEnd) { + epdPartialDisplayWithOld(data, nullptr, xStart, yStart, xEnd, yEnd); +} + +bool epdSupportsPartialRefresh() { +#if defined(EPD_PANEL_42_DKE_RY683) || defined(EPD_PANEL_42_GDEM042F52) + return false; +#else + return true; +#endif +} + +void epdPartialDisplayWithOld(uint8_t *data, const uint8_t *oldData, int xStart, int yStart, int xEnd, int yEnd) { #if defined(EPD_PANEL_42_DKE_RY683) || defined(EPD_PANEL_42_GDEM042F52) (void)data; + (void)oldData; (void)xStart; (void)yStart; (void)xEnd; @@ -586,6 +599,19 @@ void epdPartialDisplay(uint8_t *data, int xStart, int yStart, int xEnd, int yEnd for (int i = 0; i < count; i++) epdSendData(data[i]); + if (oldData) { + epdSendCommand(0x4E); // Set RAM X address counter + epdSendData(xS & 0xFF); + + epdSendCommand(0x4F); // Set RAM Y address counter + epdSendData(yStart & 0xFF); + epdSendData((yStart >> 8) & 0xFF); + + epdSendCommand(0x26); // Write old/secondary RAM for stable partial inversion + for (int i = 0; i < count; i++) + epdSendData(oldData[i]); + } + epdSendCommand(0x22); // Display Update Control 2 epdSendData(0xFF); // Partial update sequence epdSendCommand(0x20); // Activate Display Update Sequence @@ -730,6 +756,10 @@ void epdDisplayFast(const uint8_t *image) { epdDisplay(image); } +bool epdSupportsPartialRefresh() { + return false; +} + // ── Partial display (not supported on LG, fallback to full) ── void epdPartialDisplay(uint8_t *data, int xStart, int yStart, int xEnd, int yEnd) { @@ -739,6 +769,11 @@ void epdPartialDisplay(uint8_t *data, int xStart, int yStart, int xEnd, int yEnd // ── Sleep ── +void epdPartialDisplayWithOld(uint8_t *data, const uint8_t *oldData, int xStart, int yStart, int xEnd, int yEnd) { + (void)oldData; + epdPartialDisplay(data, xStart, yStart, xEnd, yEnd); +} + void epdSleep() { EPD_SendCommand(0x02); // power off EPD_WaitUntilIdle(); @@ -919,8 +954,26 @@ void epdDisplayDeepClear(const uint8_t *image) { } void epdPartialDisplay(uint8_t *data, int xStart, int yStart, int xEnd, int yEnd) { + epdPartialDisplayWithOld(data, nullptr, xStart, yStart, xEnd, yEnd); +} + +bool epdSupportsPartialRefresh() { +#if defined(EPD_PANEL_29) + return false; +#else + return true; +#endif +} + +void epdPartialDisplayWithOld(uint8_t *data, const uint8_t *oldData, int xStart, int yStart, int xEnd, int yEnd) { epdInit(); #if defined(EPD_PANEL_29) + (void)data; + (void)oldData; + (void)xStart; + (void)yStart; + (void)xEnd; + (void)yEnd; rotate_landscape_to_panel(imgBuf); display.writeImage( rotated_buffer, @@ -936,8 +989,13 @@ void epdPartialDisplay(uint8_t *data, int xStart, int yStart, int xEnd, int yEnd #else int w = xEnd - xStart; int h = yEnd - yStart; - display.writeImage(data, xStart, yStart, w, h, false, false, true); - display.epd2.writeImageAgain(data, xStart, yStart, w, h, false, false, true); + if (oldData) { + display.epd2.writeImageAgain(oldData, xStart, yStart, w, h, false, false, true); + display.writeImage(data, xStart, yStart, w, h, false, false, true); + } else { + display.writeImage(data, xStart, yStart, w, h, false, false, true); + display.epd2.writeImageAgain(data, xStart, yStart, w, h, false, false, true); + } display.refresh(xStart, yStart, w, h); #endif display.powerOff(); diff --git a/firmware/src/epd_driver.h b/firmware/src/epd_driver.h index d1627718..1ebd1f26 100644 --- a/firmware/src/epd_driver.h +++ b/firmware/src/epd_driver.h @@ -25,7 +25,9 @@ void epdDisplay2bpp(const uint8_t *image2bpp); void epdDisplayFast(const uint8_t *image); // Partial display refresh for a rectangular region +bool epdSupportsPartialRefresh(); void epdPartialDisplay(uint8_t *data, int xStart, int yStart, int xEnd, int yEnd); +void epdPartialDisplayWithOld(uint8_t *data, const uint8_t *oldData, int xStart, int yStart, int xEnd, int yEnd); // Put EPD into deep sleep mode void epdSleep(); diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 31b0723a..ceccbe94 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -129,8 +129,103 @@ static uint32_t computeChecksum(const uint8_t *buf, int len) { } // ── Forward declarations ──────────────────────────────────── +#if VOCAB_REVIEW_BUILD +static uint8_t *vocabRatingParts = nullptr; +static size_t vocabRatingPartLen = 0; +static int vocabRegionYStart = 0; +static int vocabRegionYEnd = 0; +static int vocabRatingCursor = 0; + +static int vocabReviewRegionYStart() { + return (H <= 128) ? (H * 54 / 100) : (H * 52 / 100); +} + +static int vocabReviewRegionYEnd() { + return H - max(18, H / 12); +} + +static size_t vocabReviewRegionLen() { + int y0 = vocabReviewRegionYStart(); + int y1 = vocabReviewRegionYEnd(); + if (y1 <= y0) return 0; + return (size_t)ROW_BYTES * (size_t)(y1 - y0); +} + +static bool ensureVocabRatingCache() { + size_t partLen = vocabReviewRegionLen(); + if (partLen == 0) return false; + if (vocabRatingParts && vocabRatingPartLen == partLen) return true; + if (vocabRatingParts) { + free(vocabRatingParts); + vocabRatingParts = nullptr; + } + vocabRatingParts = (uint8_t *)malloc(partLen * 3); + if (!vocabRatingParts) { + vocabRatingPartLen = 0; + Serial.println("[VOCAB] rating cache alloc failed"); + return false; + } + vocabRatingPartLen = partLen; + vocabRegionYStart = vocabReviewRegionYStart(); + vocabRegionYEnd = vocabReviewRegionYEnd(); + return true; +} + +static void copyVocabRegionToImage(const uint8_t *part) { + if (!part) return; + int regionH = vocabRegionYEnd - vocabRegionYStart; + for (int row = 0; row < regionH; row++) { + memcpy( + imgBuf + (vocabRegionYStart + row) * ROW_BYTES, + part + row * ROW_BYTES, + ROW_BYTES + ); + } +} + +static bool displayCachedVocabRating(int cursor) { + if (!vocabRatingParts || vocabRatingPartLen == 0 || !epdSupportsPartialRefresh()) { + return false; + } + cursor = ((cursor % 3) + 3) % 3; + uint8_t *oldPart = (uint8_t *)malloc(vocabRatingPartLen); + if (!oldPart) return false; + + int regionH = vocabRegionYEnd - vocabRegionYStart; + for (int row = 0; row < regionH; row++) { + memcpy( + oldPart + row * ROW_BYTES, + imgBuf + (vocabRegionYStart + row) * ROW_BYTES, + ROW_BYTES + ); + } + + uint8_t *newPart = vocabRatingParts + vocabRatingPartLen * cursor; + copyVocabRegionToImage(newPart); + epdPartialDisplayWithOld(newPart, oldPart, 0, vocabRegionYStart, W, vocabRegionYEnd); + free(oldPart); + return true; +} + +static bool fetchAndDisplayVocabPack() { + if (!ensureVocabRatingCache()) return false; + if (!fetchVocabReviewPack(vocabRatingParts, vocabRatingPartLen, vocabRegionYStart, vocabRegionYEnd)) { + return false; + } + vocabRatingCursor = 0; + ctx.vocabReviewBackSide = false; + ctx.currentRenderedModeId = VOCAB_REVIEW_MODE_ID; + cacheSave(imgBuf, IMG_BUF_LEN); + smartDisplay(imgBuf); + lastContentChecksum = computeChecksum(imgBuf, IMG_BUF_LEN); + lastRenderedPeriod = currentPeriodIndex(); + ctx.lastClockTick = millis(); + return true; +} +#endif + static void checkConfigButton(); -static void triggerImmediateRefresh(bool nextMode = false, bool keepWiFi = false); +static void triggerImmediateRefresh(bool nextMode = false, bool keepWiFi = false, bool partialVocabRating = false, const uint8_t *partialOldImage = nullptr, bool skipNtp = false); static void handleLiveMode(); static bool waitForContentReady(); static void handleFailure(const char *reason); @@ -695,7 +790,7 @@ static const int AUTO_BOOT_VOICE_RECORD_SECONDS = 3; // ── Forward declarations ──────────────────────────────────── static void checkConfigButton(); static void checkAiChatButton(); -static void triggerImmediateRefresh(bool nextMode, bool keepWiFi); +static void triggerImmediateRefresh(bool nextMode, bool keepWiFi, bool partialVocabRating, const uint8_t *partialOldImage, bool skipNtp); static void handleLiveMode(); static bool waitForContentReady(); static void handleFailure(const char *reason); @@ -928,34 +1023,80 @@ void loop() { } #if VOCAB_REVIEW_BUILD } else if (ctx.wantEnterVocabReview || ctx.wantVocabFlip || ctx.wantVocabNextRating || ctx.wantVocabSubmitRating) { - const char *action = ctx.wantEnterVocabReview ? "enter" : - (ctx.wantVocabFlip ? "flip" : - (ctx.wantVocabNextRating ? "next_rating" : "submit_rating")); + bool doEnter = ctx.wantEnterVocabReview; + bool doFlip = ctx.wantVocabFlip; + bool doNextRating = ctx.wantVocabNextRating; + bool doSubmit = ctx.wantVocabSubmitRating; ctx.wantEnterVocabReview = false; ctx.wantVocabFlip = false; ctx.wantVocabNextRating = false; ctx.wantVocabSubmitRating = false; ledFeedback("ack"); - if (WiFi.status() != WL_CONNECTED && !connectWiFi()) { - Serial.println("[VOCAB] WiFi reconnect failed, skip"); - } else if (postVocabEvent(action)) { - if (strcmp(action, "enter") == 0 || strcmp(action, "submit_rating") == 0) { - ctx.vocabReviewBackSide = false; - } else if (strcmp(action, "flip") == 0) { + + if (doFlip) { + vocabRatingCursor = 0; + if (displayCachedVocabRating(vocabRatingCursor)) { + ctx.vocabReviewBackSide = true; + Serial.println("[VOCAB] Flip from cached review-pack"); + } else { + Serial.println("[VOCAB] Cached flip unavailable, falling back to render"); + if (WiFi.status() == WL_CONNECTED || connectWiFi()) { + if (postVocabEvent("flip")) { + ctx.vocabReviewBackSide = true; + lastContentChecksum = 0; + triggerImmediateRefresh(false, true, epdSupportsPartialRefresh(), nullptr, true); + } else { + ledFeedback("fail"); + } + } else { + ledFeedback("fail"); + } + } + ctx.setupDoneAt = millis(); + } else if (doNextRating) { + vocabRatingCursor = (vocabRatingCursor + 1) % 3; + if (displayCachedVocabRating(vocabRatingCursor)) { ctx.vocabReviewBackSide = true; + Serial.printf("[VOCAB] Local rating cursor -> %d\n", vocabRatingCursor); + } else { + Serial.println("[VOCAB] Cached rating unavailable, falling back to render"); + if (WiFi.status() == WL_CONNECTED || connectWiFi()) { + if (postVocabEvent("next_rating")) { + lastContentChecksum = 0; + triggerImmediateRefresh(false, true, epdSupportsPartialRefresh(), nullptr, true); + } else { + ledFeedback("fail"); + } + } else { + ledFeedback("fail"); + } } - lastContentChecksum = 0; - triggerImmediateRefresh(false, true); - if (strcmp(action, "enter") == 0 || strcmp(action, "submit_rating") == 0) { + ctx.setupDoneAt = millis(); + } else { + const char *action = doEnter ? "enter" : "submit_rating"; + const char *ratings[] = {"forgot", "fuzzy", "remember"}; + const char *rating = doSubmit ? ratings[vocabRatingCursor % 3] : nullptr; + if (WiFi.status() != WL_CONNECTED && !connectWiFi()) { + Serial.println("[VOCAB] WiFi reconnect failed, skip"); + } else if (postVocabEvent(action, rating)) { + bool previousSuppressAbortCheck = g_suppressAbortCheck; + g_suppressAbortCheck = true; + if (!fetchAndDisplayVocabPack()) { + Serial.println("[VOCAB] review-pack unavailable, falling back to render"); + lastContentChecksum = 0; + triggerImmediateRefresh(false, true); + } + g_suppressAbortCheck = previousSuppressAbortCheck; #if defined(BOARD_HAS_AUDIO) playCurrentVocabWordAudio(); #endif + ctx.btnPressStart = 0; + ctx.ignoreConfigButtonUntilRelease = (digitalRead(PIN_CFG_BTN) == LOW); + Serial.println("[VOCAB] Keeping WiFi connected for review actions"); + ctx.setupDoneAt = millis(); + } else { + ledFeedback("fail"); } - WiFi.disconnect(true); - WiFi.mode(WIFI_OFF); - ctx.setupDoneAt = millis(); - } else { - ledFeedback("fail"); } } else if (ctx.wantSingleVoiceTurn) { #else @@ -1716,7 +1857,7 @@ static bool runAiChatConversation() { // ── Immediate refresh ─────────────────────────────────────── -static void triggerImmediateRefresh(bool nextMode, bool keepWiFi) { +static void triggerImmediateRefresh(bool nextMode, bool keepWiFi, bool partialVocabRating, const uint8_t *partialOldImage, bool skipNtp) { Serial.println("[REFRESH] Triggering immediate refresh..."); ledFeedback("ack"); if (nextMode) { @@ -1760,13 +1901,20 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi) { #else newChecksum = computeChecksum(imgBuf, IMG_BUF_LEN); #endif - syncNTP(); + if (!skipNtp) { + syncNTP(); + } if (newChecksum == lastContentChecksum && !nextMode) { Serial.println("Content unchanged, skipping display refresh"); ledFeedback("success"); } else { - Serial.println("Displaying new content..."); - smartDisplay(imgBuf); + if (partialVocabRating && ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID)) { + Serial.println("[VOCAB] Displaying reveal/control region with partial refresh..."); + updateVocabRatingRegion(partialOldImage); + } else { + Serial.println("Displaying new content..."); + smartDisplay(imgBuf); + } lastContentChecksum = newChecksum; ledFeedback("success"); Serial.println("Display done"); @@ -1918,27 +2066,46 @@ static void checkAiChatButton() { if (isPressed) { if (ctx.aiBtnPressStart == 0) { ctx.aiBtnPressStart = millis(); - } else if (!ctx.wantEnterAiChatMode && - (millis() - ctx.aiBtnPressStart >= (unsigned long)AI_CHAT_BTN_HOLD_MS)) { + } else if (!ctx.wantEnterAiChatMode) { + unsigned long holdTime = millis() - ctx.aiBtnPressStart; #if VOCAB_REVIEW_BUILD - if (ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID) && ctx.vocabReviewBackSide) { - Serial.printf("[VOCAB] Switch held for %dms, submit rating\n", AI_CHAT_BTN_HOLD_MS); - ctx.wantVocabSubmitRating = true; - } else { - Serial.printf("[VOCAB] Switch held for %dms, enter vocab review\n", AI_CHAT_BTN_HOLD_MS); - ctx.wantEnterVocabReview = true; - } + bool inVocabMode = ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID); + unsigned long holdThreshold = (inVocabMode && ctx.vocabReviewBackSide) + ? (unsigned long)VOCAB_BTN_HOLD_MS + : (unsigned long)VOCAB_ENTER_HOLD_MS; +#else + unsigned long holdThreshold = (unsigned long)AI_CHAT_BTN_HOLD_MS; +#endif + if (holdTime >= holdThreshold) { +#if VOCAB_REVIEW_BUILD + if (inVocabMode && ctx.vocabReviewBackSide) { + Serial.printf("[VOCAB] Switch held for %dms, submit rating\n", VOCAB_BTN_HOLD_MS); + ctx.wantVocabSubmitRating = true; + } else { + Serial.printf("[VOCAB] Switch held for %dms, enter vocab review\n", VOCAB_ENTER_HOLD_MS); + ctx.wantEnterVocabReview = true; + } + ctx.aiBtnPressStart = 0; #else - Serial.printf("[AI CHAT] Switch held for %dms, queue enter ai chat\n", AI_CHAT_BTN_HOLD_MS); - ctx.wantEnterAiChatMode = true; + Serial.printf("[AI CHAT] Switch held for %dms, queue enter ai chat\n", AI_CHAT_BTN_HOLD_MS); + ctx.wantEnterAiChatMode = true; + ctx.aiBtnPressStart = 0; #endif - ctx.aiBtnPressStart = 0; + } } } else { if (ctx.aiBtnPressStart > 0 && !ctx.wantEnterAiChatMode) { unsigned long duration = millis() - ctx.aiBtnPressStart; if (duration >= (unsigned long)SHORT_PRESS_MIN_MS && +#if VOCAB_REVIEW_BUILD + duration < (unsigned long)( + ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID) && ctx.vocabReviewBackSide + ? VOCAB_BTN_HOLD_MS + : VOCAB_ENTER_HOLD_MS + )) { +#else duration < (unsigned long)AI_CHAT_BTN_HOLD_MS) { +#endif #if VOCAB_REVIEW_BUILD if (ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID)) { if (ctx.vocabReviewBackSide) { diff --git a/firmware/src/network.cpp b/firmware/src/network.cpp index 5a6fa59f..794e855e 100644 --- a/firmware/src/network.cpp +++ b/firmware/src/network.cpp @@ -973,6 +973,102 @@ bool postVocabEvent(const char *action, const char *rating) { return false; } +static uint16_t readLe16(const uint8_t *p) { + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); +} + +static uint32_t readLe32(const uint8_t *p) { + return (uint32_t)p[0] + | ((uint32_t)p[1] << 8) + | ((uint32_t)p[2] << 16) + | ((uint32_t)p[3] << 24); +} + +bool fetchVocabReviewPack(uint8_t *ratingParts, size_t partLen, int yStart, int yEnd) { + if (!ratingParts || partLen == 0) return false; + if (!ensureDeviceToken()) return false; + + float v = readBatteryVoltage(); + String mac = WiFi.macAddress(); + String url = cfgServer + "/api/device/" + mac + "/vocab/review-pack" + + "?v=" + String(v, 2) + + "&w=" + String(W) + + "&h=" + String(H) + + "&y_start=" + String(yStart) + + "&y_end=" + String(yEnd); + bool useSSL = cfgServer.startsWith("https://"); + + for (int attempt = 0; attempt < 2; attempt++) { + WiFiClient plainClient; + WiFiClientSecure secClient; + HTTPClient http; + if (useSSL) { + secClient.setCACert(ROOT_CA); + http.begin(secClient, url); + } else { + http.begin(plainClient, url); + } + http.setTimeout(HTTP_TIMEOUT); + http.addHeader("Accept-Encoding", "identity"); + if (cfgDeviceToken.length() > 0) { + http.addHeader("X-Device-Token", cfgDeviceToken); + } + + int code = http.GET(); + Serial.printf("[VOCAB] GET review-pack -> %d\n", code); + if (code != 200) { + if (code < 0) { + Serial.printf("[VOCAB] review-pack error: %s\n", http.errorToString(code).c_str()); + } else { + String body = http.getString(); + Serial.printf("[VOCAB] review-pack response: %s\n", body.substring(0, 200).c_str()); + } + http.end(); + if (!recoverDeviceTokenIfUnauthorized(code)) return false; + continue; + } + + WiFiClient *stream = http.getStreamPtr(); + uint8_t header[24]; + if (!readExact(stream, header, sizeof(header))) { + http.end(); + return false; + } + if (memcmp(header, "IVP1", 4) != 0) { + Serial.println("[VOCAB] review-pack bad magic"); + http.end(); + return false; + } + uint16_t packW = readLe16(header + 4); + uint16_t packH = readLe16(header + 6); + uint16_t packYStart = readLe16(header + 8); + uint16_t packYEnd = readLe16(header + 10); + uint32_t fullLen = readLe32(header + 12); + uint32_t packPartLen = readLe32(header + 16); + uint8_t ratingCount = header[20]; + if (packW != W || packH != H || packYStart != yStart || packYEnd != yEnd || + fullLen != IMG_BUF_LEN || packPartLen != partLen || ratingCount != 3) { + Serial.printf("[VOCAB] review-pack mismatch w=%u h=%u y=%u-%u full=%u part=%u count=%u\n", + packW, packH, packYStart, packYEnd, fullLen, packPartLen, ratingCount); + http.end(); + return false; + } + if (!readExact(stream, imgBuf, IMG_BUF_LEN)) { + http.end(); + return false; + } + if (!readExact(stream, ratingParts, partLen * 3)) { + http.end(); + return false; + } + http.end(); + Serial.printf("[VOCAB] review-pack OK front=%d parts=%u\n", IMG_BUF_LEN, (unsigned)(partLen * 3)); + lastHeartbeatAt = millis(); + return true; + } + return false; +} + bool fetchVocabAudio(AudioChunkCallback onChunk, void *userData) { if (!onChunk) return false; if (!ensureDeviceToken()) return false; diff --git a/firmware/src/network.h b/firmware/src/network.h index 933d1cfc..4adc20b1 100644 --- a/firmware/src/network.h +++ b/firmware/src/network.h @@ -60,6 +60,7 @@ bool peekPendingMode(String &pendingModeOut); // POST runtime mode (active/interval) to backend. bool postRuntimeMode(const char *mode); bool postVocabEvent(const char *action, const char *rating = nullptr); +bool fetchVocabReviewPack(uint8_t *ratingParts, size_t partLen, int yStart, int yEnd); typedef void (*AudioChunkCallback)(const uint8_t *data, size_t len, void *userData); bool fetchVocabAudio(AudioChunkCallback onChunk, void *userData = nullptr); From 372beb2939cf63b307a33229ae40cd2df06f21a2 Mon Sep 17 00:00:00 2001 From: AeBoPi <109503402+AeBoPi@users.noreply.github.com> Date: Thu, 28 May 2026 10:03:49 +0800 Subject: [PATCH 5/8] Improve vocab review settings and audio flow --- backend/api/routes/device.py | 28 ++- backend/core/vocab_store.py | 2 +- backend/core/voice_service.py | 25 ++- firmware/src/config.h | 1 + firmware/src/main.cpp | 157 +++++++++++++---- firmware/src/network.cpp | 48 +++++- inksight-mobile/app/device/[mac].tsx | 6 +- .../app/device/[mac]/mode-settings.tsx | 97 ++++++++++- inksight-mobile/features/device/api.ts | 24 +-- inksight-mobile/lib/mode-display.ts | 4 + inksight-mobile/messages/en.json | 12 ++ inksight-mobile/messages/zh.json | 12 ++ webapp/app/config/page.tsx | 159 +++++++++++++++++- 13 files changed, 512 insertions(+), 63 deletions(-) diff --git a/backend/api/routes/device.py b/backend/api/routes/device.py index 25ebd5ee..b1c8dfbe 100644 --- a/backend/api/routes/device.py +++ b/backend/api/routes/device.py @@ -59,6 +59,22 @@ _device_alerts_lock = asyncio.Lock() +def _vocab_tts_text(word: str) -> str: + text = word.strip() + if text and text[-1] not in ".!?;:,。!?;:": + return f"{text}." + return text + + +def _vocab_tts_fallback_text(word: str) -> str: + text = word.strip() + if not text: + return text + if len(text) <= 3 and text.isascii() and any(ch.isalpha() for ch in text): + return f"{text}, {text}." + return f"The word is {text}." + + @router.post("/device/{mac}/refresh") async def trigger_refresh( mac: str, @@ -178,15 +194,25 @@ async def get_vocab_review_audio( if not word or content.get("state") == "empty": return Response(status_code=204) + tts_text = word try: from api.routes.voice import _resolve_device_voice_runtime_settings from core.voice_service import synthesize_prompt_pcm settings = await _resolve_device_voice_runtime_settings(mac) - audio_pcm = await synthesize_prompt_pcm(word, settings=settings) + tts_text = _vocab_tts_text(word) + audio_pcm = await synthesize_prompt_pcm(tts_text, settings=settings) + if not audio_pcm: + fallback_tts_text = _vocab_tts_fallback_text(word) + if fallback_tts_text and fallback_tts_text != tts_text: + tts_text = fallback_tts_text + audio_pcm = await synthesize_prompt_pcm(tts_text, settings=settings) except Exception as exc: logger.exception("[VOCAB] TTS failed for mac=%s word=%s", mac, word) return JSONResponse({"error": str(exc)}, status_code=500) + if not audio_pcm: + logger.warning("[VOCAB] audio TTS returned empty pcm mac=%s word=%s tts_text=%s", mac, word, tts_text) + return JSONResponse({"error": "empty_tts_audio", "word": word, "tts_text": tts_text}, status_code=502) return Response(content=audio_pcm, media_type="application/octet-stream") diff --git a/backend/core/vocab_store.py b/backend/core/vocab_store.py index 0ef6d8f3..61ddac4a 100644 --- a/backend/core/vocab_store.py +++ b/backend/core/vocab_store.py @@ -264,7 +264,7 @@ async def _select_next_item(db, mac: str, deck_id: str, daily_limit: int, new_ca FROM vocab_items vi LEFT JOIN vocab_progress vp ON vp.vocab_item_id = vi.id AND vp.mac = ? WHERE vi.deck_id = ? AND vp.vocab_item_id IS NULL - ORDER BY vi.difficulty ASC, vi.id ASC + ORDER BY RANDOM() LIMIT 1 """, (mac, deck_id), diff --git a/backend/core/voice_service.py b/backend/core/voice_service.py index f05161b9..f138873c 100644 --- a/backend/core/voice_service.py +++ b/backend/core/voice_service.py @@ -89,6 +89,7 @@ def _env_bool(name: str, default: bool) -> bool: VOICE_STREAMING_TTS_VOLUME = _env_int("VOICE_STREAMING_TTS_VOLUME", 50) VOICE_STREAMING_TTS_PITCH = _env_float("VOICE_STREAMING_TTS_PITCH", 1.0) VOICE_STREAMING_TTS_MAX_EVENT_BYTES = _env_int("VOICE_STREAMING_TTS_MAX_EVENT_BYTES", 4 * 1024 * 1024) +VOICE_PROMPT_TTS_FINISH_DELAY_MS = _env_int("VOICE_PROMPT_TTS_FINISH_DELAY_MS", 350) VOICE_DASHSCOPE_API_KEY = _env_str("VOICE_DASHSCOPE_API_KEY", "") VOICE_STT_API_KEY = _env_str("VOICE_STT_API_KEY", "") @@ -909,7 +910,7 @@ def _split_delta_tts_segments(buffer: str, *, final: bool, idle_break: bool) -> async def _synthesize_reply_pcm(reply_text: str, *, settings: VoiceRuntimeSettings) -> bytes: started_at = time.perf_counter() - bridge = _StreamingTtsBridge(settings=settings) + bridge = _StreamingTtsBridge(settings=settings, finish_delay_ms=VOICE_PROMPT_TTS_FINISH_DELAY_MS) bridge.start() bridge.feed_text(reply_text) bridge.finish() @@ -920,14 +921,17 @@ async def _synthesize_reply_pcm(reply_text: str, *, settings: VoiceRuntimeSettin audio_parts.append(chunk) pcm = b"".join(audio_parts) logger.info("[VOICE_TTS] text=%s pcm_bytes=%d elapsed_ms=%d", _preview_text(reply_text), len(pcm), _ms_since(started_at)) + if not pcm: + logger.warning("[VOICE_TTS] empty pcm text=%s elapsed_ms=%d", _preview_text(reply_text), _ms_since(started_at)) return pcm class _StreamingTtsBridge: """Bridge direct DashScope WebSocket TTS to async.""" - def __init__(self, *, settings: VoiceRuntimeSettings) -> None: + def __init__(self, *, settings: VoiceRuntimeSettings, finish_delay_ms: int = 0) -> None: self._settings = settings + self._finish_delay_ms = max(0, finish_delay_ms) self._loop = asyncio.get_running_loop() self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._text_queue: queue.Queue[str | None] = queue.Queue() @@ -1034,6 +1038,8 @@ async def _sender() -> None: while True: text = await asyncio.to_thread(self._text_queue.get) if text is None: + if send_started and self._finish_delay_ms > 0: + await asyncio.sleep(self._finish_delay_ms / 1000) await ws.send( json.dumps( { @@ -1103,7 +1109,9 @@ async def _receiver() -> None: if event == "task-failed": finished_event.set() message = json.dumps(payload, ensure_ascii=False) - logger.warning("[VOICE_TTS_STREAM] task-failed (non-fatal): %s", message) + logger.warning("[VOICE_TTS_STREAM] task_failed: %s", message) + if self._first_audio_at <= 0: + raise RuntimeError(f"DashScope TTS task failed before audio: {message}") return sender_task = asyncio.create_task(_sender()) @@ -1144,9 +1152,16 @@ async def synthesize_prompt_pcm(text: str, settings: VoiceRuntimeSettings | None ) cached = _voice_prompt_cache.get(cache_key) if cached is not None: - return cached + if not cached: + logger.warning("[VOICE_TTS] evict empty prompt cache text=%s", _preview_text(text)) + _voice_prompt_cache.pop(cache_key, None) + else: + return cached audio_pcm = await _synthesize_reply_pcm(text, settings=effective_settings) - _voice_prompt_cache[cache_key] = audio_pcm + if audio_pcm: + _voice_prompt_cache[cache_key] = audio_pcm + else: + logger.warning("[VOICE_TTS] prompt cache skip empty text=%s", _preview_text(text)) return audio_pcm diff --git a/firmware/src/config.h b/firmware/src/config.h index e1635da2..472bc498 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -113,6 +113,7 @@ static const int CFG_BTN_HOLD_MS = 2000; // Long press duration to trigger static const int AI_CHAT_BTN_HOLD_MS = 3000; // Long press duration to enter AI chat mode static const int VOCAB_ENTER_HOLD_MS = 2000; // Long press duration to enter vocab review static const int VOCAB_BTN_HOLD_MS = 1500; // Long press duration to submit vocab rating +static const int VOCAB_EXIT_HOLD_MS = 5000; // Long press duration to exit vocab review static const int SHORT_PRESS_MIN_MS = 50; // Minimum short press duration (debounce) static const int LIVE_POLL_MS = 5000; // Poll interval for pending remote actions static const int LIVE_WIFI_RETRY_MS = 5000; // Retry interval when WiFi is disconnected diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index ceccbe94..6630cf7b 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -93,6 +93,7 @@ struct DeviceContext { unsigned long btnPressStart = 0; unsigned long aiBtnPressStart = 0; bool ignoreConfigButtonUntilRelease = false; + bool ignoreAiButtonUntilRelease = false; bool liveMode = false; unsigned long lastLivePollAt = 0; unsigned long lastLiveWiFiRetryAt = 0; @@ -110,6 +111,7 @@ struct DeviceContext { bool wantVocabFlip = false; bool wantVocabNextRating = false; bool wantVocabSubmitRating = false; + bool wantVocabExit = false; bool vocabReviewBackSide = false; String currentRenderedModeId; String switchToModeId; @@ -325,10 +327,44 @@ static void drainSendQueue(AudioService &as) { } } +struct VocabAudioPlaybackCtx { + AudioCodec *codec = nullptr; + size_t bytesWritten = 0; + size_t bytesDropped = 0; + uint8_t pendingByte = 0; + bool hasPendingByte = false; +}; + static void vocabAudioChunkCallback(const uint8_t *data, size_t len, void *userData) { - AudioService *audioService = static_cast(userData); - if (audioService) { - audioService->PushPcmForPlayback(data, len, 1); + VocabAudioPlaybackCtx *ctx = static_cast(userData); + if (!ctx || !ctx->codec || !data || len == 0) return; + + const uint8_t *pcm = data; + size_t pcmLen = len; + int16_t firstSample = 0; + if (ctx->hasPendingByte && pcmLen > 0) { + firstSample = (int16_t)((uint16_t)ctx->pendingByte | ((uint16_t)pcm[0] << 8)); + int written = ctx->codec->Write(&firstSample, 1); + if (written == 1) ctx->bytesWritten += 2; + else ctx->bytesDropped += 2; + pcm++; + pcmLen--; + ctx->hasPendingByte = false; + } + + if (pcmLen >= 2) { + size_t evenLen = pcmLen & ~((size_t)1); + int samples = (int)(evenLen / sizeof(int16_t)); + int written = ctx->codec->Write((const int16_t *)pcm, samples); + if (written > 0) ctx->bytesWritten += (size_t)written * sizeof(int16_t); + if (written < samples) ctx->bytesDropped += (size_t)(samples - written) * sizeof(int16_t); + pcm += evenLen; + pcmLen -= evenLen; + } + + if (pcmLen == 1) { + ctx->pendingByte = pcm[0]; + ctx->hasPendingByte = true; } } @@ -341,26 +377,28 @@ static void playCurrentVocabWordAudio() { return; } - static AudioService audioService; - if (!audioService.Initialize(&codec)) { - Serial.println("[VOCAB] AudioService init failed"); + codec.EnableOutput(true); + if (!codec.outputEnabled()) { + Serial.println("[VOCAB] codec output enable failed"); codec.Stop(); return; } - audioService.Start(); - bool ok = fetchVocabAudio(vocabAudioChunkCallback, &audioService); + VocabAudioPlaybackCtx playbackCtx; + playbackCtx.codec = &codec; + bool ok = fetchVocabAudio(vocabAudioChunkCallback, &playbackCtx); if (!ok) { Serial.println("[VOCAB] audio fetch failed"); } - - unsigned long drainStart = millis(); - while (!audioService.IsPlaybackEmpty() && millis() - drainStart < 5000) { - delay(20); + if (playbackCtx.hasPendingByte) { + playbackCtx.bytesDropped += 1; } - delay(80); - audioService.Stop(); - audioService.ResetPlayback(); + if (playbackCtx.bytesDropped > 0) { + Serial.printf("[VOCAB] audio dropped %u bytes\n", (unsigned int)playbackCtx.bytesDropped); + } + + unsigned long tailMs = playbackCtx.bytesWritten > 0 ? 250 : 80; + delay(tailMs); codec.Stop(); #endif } @@ -1022,18 +1060,36 @@ void loop() { } } #if VOCAB_REVIEW_BUILD - } else if (ctx.wantEnterVocabReview || ctx.wantVocabFlip || ctx.wantVocabNextRating || ctx.wantVocabSubmitRating) { + } else if (ctx.wantEnterVocabReview || ctx.wantVocabFlip || ctx.wantVocabNextRating || ctx.wantVocabSubmitRating || ctx.wantVocabExit) { bool doEnter = ctx.wantEnterVocabReview; bool doFlip = ctx.wantVocabFlip; bool doNextRating = ctx.wantVocabNextRating; bool doSubmit = ctx.wantVocabSubmitRating; + bool doExit = ctx.wantVocabExit; ctx.wantEnterVocabReview = false; ctx.wantVocabFlip = false; ctx.wantVocabNextRating = false; ctx.wantVocabSubmitRating = false; + ctx.wantVocabExit = false; ledFeedback("ack"); - if (doFlip) { + if (doExit) { + Serial.println("[VOCAB] Exit vocab review, showing next mode"); + ctx.vocabReviewBackSide = false; + ctx.currentRenderedModeId = ""; + lastContentChecksum = 0; + bool previousSuppressAbortCheck = g_suppressAbortCheck; + g_suppressAbortCheck = true; + triggerImmediateRefresh(true, true); + g_suppressAbortCheck = previousSuppressAbortCheck; + ctx.btnPressStart = 0; + ctx.aiBtnPressStart = 0; + ctx.ignoreConfigButtonUntilRelease = (digitalRead(PIN_CFG_BTN) == LOW); +#if PIN_AI_CHAT_SW >= 0 + ctx.ignoreAiButtonUntilRelease = (digitalRead(PIN_AI_CHAT_SW) == LOW); +#endif + ctx.setupDoneAt = millis(); + } else if (doFlip) { vocabRatingCursor = 0; if (displayCachedVocabRating(vocabRatingCursor)) { ctx.vocabReviewBackSide = true; @@ -1860,9 +1916,21 @@ static bool runAiChatConversation() { static void triggerImmediateRefresh(bool nextMode, bool keepWiFi, bool partialVocabRating, const uint8_t *partialOldImage, bool skipNtp) { Serial.println("[REFRESH] Triggering immediate refresh..."); ledFeedback("ack"); + uint8_t *previousImage = nullptr; if (nextMode) { + previousImage = (uint8_t *)malloc(IMG_BUF_LEN); + if (previousImage) { + memcpy(previousImage, imgBuf, IMG_BUF_LEN); + } showModePreview("NEXT"); } + auto restorePreviousImage = [&]() { + if (nextMode && previousImage) { + memcpy(imgBuf, previousImage, IMG_BUF_LEN); + Serial.println("[REFRESH] Restoring previous image after failed next-mode refresh"); + smartDisplay(imgBuf); + } + }; bool connected = (WiFi.status() == WL_CONNECTED); if (!connected) { ledFeedback("connecting"); @@ -1926,6 +1994,7 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi, bool partialVo bool exited = runAiChatConversation(); if (g_userAborted) { Serial.println("User aborted AI chat -> portal"); + if (previousImage) free(previousImage); enterPortalMode(); return; } @@ -1946,12 +2015,25 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi, bool partialVo lastRenderedPeriod = currentPeriodIndex(); ctx.lastClockTick = millis(); } else { - Serial.println("Fetch failed, retrying after reconnect..."); - WiFi.disconnect(true); - delay(300); - if (connectWiFi()) { + bool retryReady = false; + if (keepWiFiEffective && WiFi.status() == WL_CONNECTED) { + Serial.println("Fetch failed, retrying on existing WiFi..."); + retryReady = true; + } else { + Serial.println("Fetch failed, retrying after reconnect..."); + WiFi.disconnect(true); + delay(300); + retryReady = connectWiFi(); + } + if (retryReady) { fetched = fetchBMP(nextMode, nullptr, &renderedModeId); if (fetched) { + if (renderedModeId.length() > 0) { + ctx.currentRenderedModeId = renderedModeId; + if (!ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID)) { + ctx.vocabReviewBackSide = false; + } + } cacheSave(imgBuf, IMG_BUF_LEN); uint32_t retryChecksum = computeChecksum(imgBuf, IMG_BUF_LEN); syncNTP(); @@ -1964,10 +2046,12 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi, bool partialVo } else { ledFeedback("fail"); Serial.println("Retry also failed, keeping old content"); + restorePreviousImage(); } } else { ledFeedback("fail"); Serial.println("WiFi reconnect failed, keeping old content"); + restorePreviousImage(); } } if (!keepWiFiEffective) { @@ -1977,7 +2061,9 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi, bool partialVo } else { ledFeedback("fail"); Serial.println("WiFi reconnect failed"); + restorePreviousImage(); } + if (previousImage) free(previousImage); } static bool waitForContentReady() { @@ -2063,6 +2149,14 @@ static void checkAiChatButton() { return; #else bool isPressed = (digitalRead(PIN_AI_CHAT_SW) == LOW); + if (ctx.ignoreAiButtonUntilRelease) { + if (!isPressed) { + ctx.ignoreAiButtonUntilRelease = false; + } + ctx.aiBtnPressStart = 0; + return; + } + if (isPressed) { if (ctx.aiBtnPressStart == 0) { ctx.aiBtnPressStart = millis(); @@ -2070,26 +2164,28 @@ static void checkAiChatButton() { unsigned long holdTime = millis() - ctx.aiBtnPressStart; #if VOCAB_REVIEW_BUILD bool inVocabMode = ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID); - unsigned long holdThreshold = (inVocabMode && ctx.vocabReviewBackSide) - ? (unsigned long)VOCAB_BTN_HOLD_MS + unsigned long holdThreshold = inVocabMode + ? (unsigned long)VOCAB_EXIT_HOLD_MS : (unsigned long)VOCAB_ENTER_HOLD_MS; #else unsigned long holdThreshold = (unsigned long)AI_CHAT_BTN_HOLD_MS; #endif if (holdTime >= holdThreshold) { #if VOCAB_REVIEW_BUILD - if (inVocabMode && ctx.vocabReviewBackSide) { - Serial.printf("[VOCAB] Switch held for %dms, submit rating\n", VOCAB_BTN_HOLD_MS); - ctx.wantVocabSubmitRating = true; + if (inVocabMode) { + Serial.printf("[VOCAB] Switch held for %dms, exit vocab review\n", VOCAB_EXIT_HOLD_MS); + ctx.wantVocabExit = true; } else { Serial.printf("[VOCAB] Switch held for %dms, enter vocab review\n", VOCAB_ENTER_HOLD_MS); ctx.wantEnterVocabReview = true; } ctx.aiBtnPressStart = 0; + ctx.ignoreAiButtonUntilRelease = true; #else Serial.printf("[AI CHAT] Switch held for %dms, queue enter ai chat\n", AI_CHAT_BTN_HOLD_MS); ctx.wantEnterAiChatMode = true; ctx.aiBtnPressStart = 0; + ctx.ignoreAiButtonUntilRelease = true; #endif } } @@ -2099,8 +2195,8 @@ static void checkAiChatButton() { if (duration >= (unsigned long)SHORT_PRESS_MIN_MS && #if VOCAB_REVIEW_BUILD duration < (unsigned long)( - ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID) && ctx.vocabReviewBackSide - ? VOCAB_BTN_HOLD_MS + ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID) + ? VOCAB_EXIT_HOLD_MS : VOCAB_ENTER_HOLD_MS )) { #else @@ -2108,7 +2204,10 @@ static void checkAiChatButton() { #endif #if VOCAB_REVIEW_BUILD if (ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID)) { - if (ctx.vocabReviewBackSide) { + if (ctx.vocabReviewBackSide && duration >= (unsigned long)VOCAB_BTN_HOLD_MS) { + Serial.printf("[VOCAB] Switch released after %lums, submit rating\n", duration); + ctx.wantVocabSubmitRating = true; + } else if (ctx.vocabReviewBackSide) { Serial.printf("[VOCAB] Short press %lums, next rating\n", duration); ctx.wantVocabNextRating = true; } else { diff --git a/firmware/src/network.cpp b/firmware/src/network.cpp index 794e855e..1bc3e8a0 100644 --- a/firmware/src/network.cpp +++ b/firmware/src/network.cpp @@ -193,12 +193,15 @@ static bool readExact(WiFiClient *s, uint8_t *buf, int len) { int got = 0; unsigned long t0 = millis(); while (got < len) { - if (!s->connected() && !s->available()) { - Serial.printf("readExact: disconnected %d/%d\n", got, len); - return false; - } if (millis() - t0 > 10000) { - Serial.printf("readExact: timeout %d/%d\n", got, len); + Serial.printf( + "readExact: timeout %d/%d connected=%d available=%d wifi=%d\n", + got, + len, + s->connected() ? 1 : 0, + s->available(), + WiFi.status() + ); return false; } int avail = s->available(); @@ -206,6 +209,8 @@ static bool readExact(WiFiClient *s, uint8_t *buf, int len) { int r = s->readBytes(buf + got, min(avail, len - got)); got += r; t0 = millis(); // Reset timeout on progress + } else { + delay(1); } } return true; @@ -589,22 +594,33 @@ bool fetchBMP(bool nextMode, bool *isFallback, String *renderedModeIdOut) { bool useSSL = cfgServer.startsWith("https://"); for (int attempt = 0; attempt < 2; attempt++) { - if (checkAbort()) return false; + if (checkAbort()) { + Serial.println("[RENDER] fetchBMP aborted before HTTP"); + return false; + } WiFiClient plainClient; WiFiClientSecure secClient; HTTPClient http; + bool begun = false; if (useSSL) { secClient.setCACert(ROOT_CA); - http.begin(secClient, url); + begun = http.begin(secClient, url); } else { - http.begin(plainClient, url); + begun = http.begin(plainClient, url); } + if (!begun) { + Serial.println("[RENDER] http.begin failed"); + http.end(); + return false; + } + http.setReuse(false); http.setTimeout(HTTP_TIMEOUT); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); const char *headerKeys[] = {"X-Content-Fallback", "X-Refresh-Minutes", "X-Mode-Id"}; http.collectHeaders(headerKeys, 3); http.addHeader("Accept-Encoding", "identity"); + http.addHeader("Connection", "close"); if (cfgDeviceToken.length() > 0) { http.addHeader("X-Device-Token", cfgDeviceToken); } @@ -1091,6 +1107,7 @@ bool fetchVocabAudio(AudioChunkCallback onChunk, void *userData) { } int code = http.GET(); + int contentLen = http.getSize(); if (code == 204) { Serial.println("[VOCAB] audio -> 204 no content"); http.end(); @@ -1099,16 +1116,31 @@ bool fetchVocabAudio(AudioChunkCallback onChunk, void *userData) { if (code == 200) { WiFiClient *stream = http.getStreamPtr(); uint8_t buffer[1024]; + size_t totalRead = 0; + unsigned long lastDataAt = millis(); while (http.connected() || stream->available()) { int available = stream->available(); if (available <= 0) { + if (contentLen >= 0 && totalRead >= (size_t)contentLen) { + break; + } + if (millis() - lastDataAt > 3000) { + Serial.printf("[VOCAB] audio read timeout total=%u expected=%d connected=%d\n", + (unsigned int)totalRead, contentLen, http.connected() ? 1 : 0); + break; + } delay(1); continue; } int readLen = stream->readBytes(buffer, min(available, (int)sizeof(buffer))); if (readLen > 0) { + totalRead += (size_t)readLen; + lastDataAt = millis(); onChunk(buffer, (size_t)readLen, userData); } + if (contentLen >= 0 && totalRead >= (size_t)contentLen) { + break; + } } http.end(); return true; diff --git a/inksight-mobile/app/device/[mac].tsx b/inksight-mobile/app/device/[mac].tsx index a45a8828..4d5b85ba 100644 --- a/inksight-mobile/app/device/[mac].tsx +++ b/inksight-mobile/app/device/[mac].tsx @@ -101,8 +101,8 @@ export default function DeviceDetailScreen() { enabled: Boolean(mac && token), }); const modesQuery = useQuery({ - queryKey: ['mode-catalog-detail'], - queryFn: listModes, + queryKey: ['mode-catalog-detail', mac, token], + queryFn: () => listModes({ token: token || undefined, mac: mac || undefined }), }); const widgetQuery = useQuery({ queryKey: ['device-widget', mac, token, selectedWidgetMode], @@ -248,7 +248,7 @@ export default function DeviceDetailScreen() { return t('device.widgetEmpty'); } - const HARDCODED_CONFIGURABLE = ['CALENDAR', 'TIMETABLE', 'MY_ADAPTIVE']; + const HARDCODED_CONFIGURABLE = ['CALENDAR', 'TIMETABLE', 'MY_ADAPTIVE', 'VOCAB_REVIEW']; function isModeConfigurable(modeId: string): boolean { if (HARDCODED_CONFIGURABLE.includes(modeId.toUpperCase())) return true; diff --git a/inksight-mobile/app/device/[mac]/mode-settings.tsx b/inksight-mobile/app/device/[mac]/mode-settings.tsx index 3d3eede1..e90eeadf 100644 --- a/inksight-mobile/app/device/[mac]/mode-settings.tsx +++ b/inksight-mobile/app/device/[mac]/mode-settings.tsx @@ -17,6 +17,7 @@ import { theme } from '@/lib/theme'; type CountdownEvent = { name: string; date: string; type?: string }; type Reminder = { month: string; day: string; text: string }; +type VocabDeck = { id: string; labelKey: string; fallback: string }; const DEFAULT_PERIODS = ['08:00-09:30', '10:00-11:30', '14:00-15:30', '16:00-17:30']; const DEFAULT_COURSES: Record = { @@ -27,6 +28,24 @@ const DEFAULT_COURSES: Record = { '4-0': '操作系统/C102', }; const WEEKDAYS = 5; +const DEFAULT_VOCAB_DECK_ID = 'primary_en'; +const DEFAULT_VOCAB_DAILY_LIMIT = 30; +const VOCAB_DECKS: VocabDeck[] = [ + { id: 'primary_en', labelKey: 'ms.vocabDeckPrimary', fallback: 'Primary English' }, + { id: 'middle_school_en', labelKey: 'ms.vocabDeckMiddle', fallback: 'Middle School English' }, + { id: 'high_school_en', labelKey: 'ms.vocabDeckHigh', fallback: 'High School English' }, + { id: 'cet4_en', labelKey: 'ms.vocabDeckCet4', fallback: 'CET-4' }, + { id: 'cet6_en', labelKey: 'ms.vocabDeckCet6', fallback: 'CET-6' }, + { id: 'ielts_en', labelKey: 'ms.vocabDeckIelts', fallback: 'IELTS' }, + { id: 'toefl_en', labelKey: 'ms.vocabDeckToefl', fallback: 'TOEFL' }, + { id: 'core_en', labelKey: 'ms.vocabDeckCore', fallback: 'Core English' }, +]; + +function clampVocabDailyLimit(raw: string) { + const parsed = parseInt(raw, 10); + if (Number.isNaN(parsed)) return DEFAULT_VOCAB_DAILY_LIMIT; + return Math.max(1, Math.min(200, parsed)); +} export default function ModeSettingsScreen() { const { locale, t } = useI18n(); @@ -44,7 +63,7 @@ export default function ModeSettingsScreen() { }); const modesQuery = useQuery({ queryKey: ['mode-catalog-ms'], - queryFn: listModes, + queryFn: () => listModes({ token: token || undefined, mac: mac || undefined }), }); const existing = configQuery.data?.modeOverrides?.[modeId] ?? {}; @@ -74,6 +93,10 @@ export default function ModeSettingsScreen() { const [adaptiveImageUrls, setAdaptiveImageUrls] = useState([]); const [adaptiveUploading, setAdaptiveUploading] = useState(false); + // --- VOCAB_REVIEW --- + const [vocabDeckId, setVocabDeckId] = useState(DEFAULT_VOCAB_DECK_ID); + const [vocabDailyLimit, setVocabDailyLimit] = useState(String(DEFAULT_VOCAB_DAILY_LIMIT)); + useEffect(() => { if (!configQuery.data) return; const ov = configQuery.data.modeOverrides?.[modeId] ?? {}; @@ -117,6 +140,9 @@ export default function ModeSettingsScreen() { } else { setAdaptiveImageUrls([]); } + } else if (modeId === 'VOCAB_REVIEW') { + setVocabDeckId(String(ov.deck_id || DEFAULT_VOCAB_DECK_ID)); + setVocabDailyLimit(String(ov.daily_limit || DEFAULT_VOCAB_DAILY_LIMIT)); } else { const sv: Record = {}; for (const [k, v] of Object.entries(ov)) { @@ -155,6 +181,11 @@ export default function ModeSettingsScreen() { } else if (modeId === 'MY_ADAPTIVE') { base.image_urls = [...adaptiveImageUrls]; base.image_url = adaptiveImageUrls[0] || ''; + } else if (modeId === 'VOCAB_REVIEW') { + const dailyLimit = clampVocabDailyLimit(vocabDailyLimit); + base.deck_id = VOCAB_DECKS.some((deck) => deck.id === vocabDeckId) ? vocabDeckId : DEFAULT_VOCAB_DECK_ID; + base.daily_limit = dailyLimit; + base.new_cards_per_day = dailyLimit; } else { for (const [k, v] of Object.entries(schemaValues)) { if (v.trim()) base[k] = v.trim(); @@ -542,7 +573,52 @@ export default function ModeSettingsScreen() { ); } - const hasCustomEditor = ['WEATHER', 'MEMO', 'COUNTDOWN', 'CALENDAR', 'TIMETABLE', 'MY_ADAPTIVE'].includes(modeId); + function renderVocabReview() { + return ( + + {t('ms.vocabDeck')} + {t('ms.vocabHint')} + + {VOCAB_DECKS.map((deck) => { + const active = vocabDeckId === deck.id; + return ( + setVocabDeckId(deck.id)} + style={styles.deckButton} + /> + ); + })} + + + + {t('ms.vocabDailyGoal')} + setVocabDailyLimit(v.replace(/\D/g, '').slice(0, 3))} + onBlur={() => setVocabDailyLimit(String(clampVocabDailyLimit(vocabDailyLimit)))} + keyboardType="number-pad" + placeholder="30" + placeholderTextColor={theme.colors.tertiary} + /> + + + { + setVocabDeckId(DEFAULT_VOCAB_DECK_ID); + setVocabDailyLimit(String(DEFAULT_VOCAB_DAILY_LIMIT)); + }} + /> + + ); + } + + const hasCustomEditor = ['WEATHER', 'MEMO', 'COUNTDOWN', 'CALENDAR', 'TIMETABLE', 'MY_ADAPTIVE', 'VOCAB_REVIEW'].includes(modeId); return ( @@ -555,6 +631,7 @@ export default function ModeSettingsScreen() { {modeId === 'CALENDAR' && renderCalendar()} {modeId === 'TIMETABLE' && renderTimetable()} {modeId === 'MY_ADAPTIVE' && renderAdaptive()} + {modeId === 'VOCAB_REVIEW' && renderVocabReview()} {!hasCustomEditor && (schema.length > 0 ? renderGenericSchema() : ( {t('device.modeSettingsNoSchema')} ))} @@ -595,6 +672,22 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: 10, }, + fieldGap: { + marginTop: 14, + }, + helpText: { + fontSize: 12, + lineHeight: 18, + marginBottom: 12, + }, + deckGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + deckButton: { + marginBottom: 0, + }, rowBetween: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/inksight-mobile/features/device/api.ts b/inksight-mobile/features/device/api.ts index 9cee11e1..ddff8642 100644 --- a/inksight-mobile/features/device/api.ts +++ b/inksight-mobile/features/device/api.ts @@ -1,4 +1,5 @@ import { apiFetch, apiRequest, buildApiUrl } from '@/lib/api-client'; +import * as FileSystem from 'expo-file-system/legacy'; export type DeviceSummary = { mac: string; @@ -279,22 +280,23 @@ export async function pushPreviewImageToDevice(mac: string, token: string, previ } export async function uploadImage(uri: string, mimeType: string, fileName: string): Promise { - const fd = new FormData(); - fd.append("file", { - uri, - name: fileName || "photo.jpg", - type: mimeType || "image/jpeg", - } as any); - const resp = await apiFetch("/uploads", { method: "POST", body: fd, contentType: null }); - if (!resp.ok) { - let msg = `upload failed: ${resp.status}`; + const uploadType = mimeType || (fileName?.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'); + const result = await FileSystem.uploadAsync(buildApiUrl('/uploads'), uri, { + httpMethod: 'POST', + uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT, + headers: { + 'x-upload-content-type': uploadType, + }, + }); + if (result.status < 200 || result.status >= 300) { + let msg = `upload failed: ${result.status}`; try { - const payload = await resp.json(); + const payload = JSON.parse(result.body || '{}') as { message?: string; error?: string }; msg = payload.message || payload.error || msg; } catch {} throw new Error(msg); } - const data = (await resp.json()) as { url?: string }; + const data = JSON.parse(result.body || '{}') as { url?: string }; if (!data.url) throw new Error("upload failed: missing url"); return data.url; } diff --git a/inksight-mobile/lib/mode-display.ts b/inksight-mobile/lib/mode-display.ts index 52360e89..02bb4c24 100644 --- a/inksight-mobile/lib/mode-display.ts +++ b/inksight-mobile/lib/mode-display.ts @@ -113,6 +113,10 @@ const BUILTIN: Record = { zh: { name: '课程表', tip: '展示今日或本周课程安排' }, en: { name: 'Timetable', tip: 'Show today or weekly class schedule' }, }, + VOCAB_REVIEW: { + zh: { name: '背单词', tip: '按词库和每日目标进行单词复习' }, + en: { name: 'Vocab Review', tip: 'Review vocabulary by deck and daily goal' }, + }, }; export function modeDisplayName(modeId: string, locale: 'zh' | 'en', apiFallback: string) { diff --git a/inksight-mobile/messages/en.json b/inksight-mobile/messages/en.json index e25fc053..2e4d3969 100644 --- a/inksight-mobile/messages/en.json +++ b/inksight-mobile/messages/en.json @@ -357,6 +357,18 @@ "ms.adaptiveUploadFailed": "Upload failed", "ms.adaptiveMaxReached": "Maximum 6 images reached", "ms.adaptiveSelectPhoto": "Select Photo", + "ms.vocabDeck": "Deck", + "ms.vocabHint": "Choose a vocabulary deck and set the daily review goal. The next vocab review session will use these settings.", + "ms.vocabDailyGoal": "Daily Goal", + "ms.vocabSaveDefault": "Use Default", + "ms.vocabDeckPrimary": "Primary English", + "ms.vocabDeckMiddle": "Middle School English", + "ms.vocabDeckHigh": "High School English", + "ms.vocabDeckCet4": "CET-4", + "ms.vocabDeckCet6": "CET-6", + "ms.vocabDeckIelts": "IELTS", + "ms.vocabDeckToefl": "TOEFL", + "ms.vocabDeckCore": "Core English", "firmware.title": "Firmware", "firmware.subtitle": "Check the latest release and package information.", "firmware.selectVariant": "Select firmware variant", diff --git a/inksight-mobile/messages/zh.json b/inksight-mobile/messages/zh.json index 42f6df74..97a63379 100644 --- a/inksight-mobile/messages/zh.json +++ b/inksight-mobile/messages/zh.json @@ -357,6 +357,18 @@ "ms.adaptiveUploadFailed": "上传失败", "ms.adaptiveMaxReached": "最多只能添加 6 张图片", "ms.adaptiveSelectPhoto": "选择照片", + "ms.vocabDeck": "词库", + "ms.vocabHint": "选择词库并设置每日目标。保存后,下次进入背词模式会使用新的设置。", + "ms.vocabDailyGoal": "每日目标", + "ms.vocabSaveDefault": "使用默认", + "ms.vocabDeckPrimary": "小学英语", + "ms.vocabDeckMiddle": "初中英语", + "ms.vocabDeckHigh": "高中英语", + "ms.vocabDeckCet4": "四级词汇", + "ms.vocabDeckCet6": "六级词汇", + "ms.vocabDeckIelts": "雅思词汇", + "ms.vocabDeckToefl": "托福词汇", + "ms.vocabDeckCore": "核心英语", "firmware.title": "固件升级", "firmware.subtitle": "查看最新固件发布与安装包信息。", "firmware.selectVariant": "选择固件版本", diff --git a/webapp/app/config/page.tsx b/webapp/app/config/page.tsx index ab963937..e2a5aaf5 100644 --- a/webapp/app/config/page.tsx +++ b/webapp/app/config/page.tsx @@ -465,7 +465,7 @@ interface PendingPreviewConfirm { usageSource?: string; } -type ParamModalType = "quote" | "weather" | "memo" | "countdown" | "habit" | "lifebar" | "calendar" | "timetable"; +type ParamModalType = "quote" | "weather" | "memo" | "countdown" | "habit" | "lifebar" | "calendar" | "timetable" | "vocab"; interface ParamModalState { type: ParamModalType; mode: string; @@ -814,6 +814,8 @@ function ConfigPageInner() { ); const [userAge, setUserAge] = useState(30); const [lifeExpectancy, setLifeExpectancy] = useState<100 | 120>(100); + const [vocabDeckId, setVocabDeckId] = useState("primary_en"); + const [vocabDailyLimit, setVocabDailyLimit] = useState(30); const [timetableData, setTimetableData] = useState({ style: "weekly", periods: ["08:00-09:30", "10:00-11:30", "14:00-15:30", "16:00-17:30"], @@ -1186,7 +1188,7 @@ function ConfigPageInner() { const requiresParamModal = useCallback((modeId: string) => { const m = (modeId || "").toUpperCase(); - return m === "WEATHER" || m === "MEMO" || m === "MY_QUOTE" || m === "COUNTDOWN" || m === "HABIT" || m === "LIFEBAR" || m === "CALENDAR" || m === "TIMETABLE"; + return m === "WEATHER" || m === "MEMO" || m === "MY_QUOTE" || m === "COUNTDOWN" || m === "HABIT" || m === "LIFEBAR" || m === "CALENDAR" || m === "TIMETABLE" || m === "VOCAB_REVIEW"; }, []); const openParamModal = useCallback((modeId: string, action: "preview" | "apply") => { @@ -1248,7 +1250,14 @@ function ConfigPageInner() { setParamModal({ type: "timetable", mode: m, action }); return; } - }, [memoText, modeOverrides]); + if (m === "VOCAB_REVIEW") { + const ov = modeOverrides[m] || {}; + setVocabDeckId(String(ov.deck_id || "primary_en")); + setVocabDailyLimit(Number(ov.daily_limit || 30)); + setParamModal({ type: "vocab", mode: m, action }); + return; + } + }, [modeOverrides]); const clearModeOverride = useCallback((modeId: string) => { setModeOverrides((prev) => { @@ -2180,6 +2189,81 @@ function ConfigPageInner() { } }, [clearModeOverride, handlePreview, selectedModes, showToast, toggleMode, tr, updateModeOverride]); + const saveVocabReviewSettings = useCallback(async (modeId: string, override: ModeOverride) => { + if (!mac) { + showToast(tr("请先完成刷机和配网以获取设备 MAC", "Please flash and provision to get device MAC"), "error"); + return; + } + if (macAccessDenied) { + showToast(tr("你无权配置该设备", "No permission to configure this device"), "error"); + return; + } + + const normalizedModeId = modeId.toUpperCase(); + const nextModeOverrides = { + ...modeOverrides, + [normalizedModeId]: sanitizeModeOverride({ + ...(modeOverrides[normalizedModeId] || {}), + ...override, + }), + }; + const normalizedModeOverrides: Record = Object.fromEntries( + Object.entries(nextModeOverrides) + .map(([id, ov]) => [id.toUpperCase(), sanitizeModeOverride(ov)] as const) + .filter(([, ov]) => Object.keys(ov).length > 0), + ); + + setSaving(true); + try { + const body: Record = { + mac, + modes: Array.from(selectedModes), + refreshStrategy: strategy, + refreshInterval: refreshMin, + ...currentLocation, + modeLanguage, + contentTone, + characterTones, + modeOverrides: normalizedModeOverrides, + memoText, + is_focus_listening: isFocusListening, + always_active: alwaysActive, + timeSlotRules, + }; + const res = await fetch("/api/config", { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error("Save failed"); + setModeOverrides(normalizedModeOverrides); + setParamModal(null); + showToast(tr("背单词设置已保存", "Vocab review settings saved"), "success"); + } catch { + showToast(tr("保存失败", "Save failed"), "error"); + } finally { + setSaving(false); + } + }, [ + alwaysActive, + characterTones, + contentTone, + currentLocation, + isFocusListening, + mac, + macAccessDenied, + memoText, + modeLanguage, + modeOverrides, + refreshMin, + sanitizeModeOverride, + selectedModes, + showToast, + strategy, + timeSlotRules, + tr, + ]); + const handlePreviewFromSettings = (addToCarousel: boolean) => { if (!settingsMode) return; const modeId = settingsMode; @@ -3629,6 +3713,8 @@ function ConfigPageInner() { ? tr("日历提醒", "Calendar Reminders") : paramModal.type === "timetable" ? tr("课程表设置", "Timetable Settings") + : paramModal.type === "vocab" + ? tr("背单词设置", "Vocab Review Settings") : tr("人生进度条", "Life Progress")} + ) : paramModal.type === "vocab" ? ( + <> +
+ {tr( + "选择词库并设置每日目标。保存后,下次进入背词模式会使用新的设置。", + "Choose a deck and set the daily goal. The next vocab review session will use the saved settings.", + )} +
+
+
+ + +
+
+ + setVocabDailyLimit(Math.max(1, Math.min(200, Number(e.target.value) || 30)))} + className="w-full rounded-sm border border-ink/20 px-3 py-2 text-sm bg-white" + /> +
+
+
+ + +
+ ) : null} From 48c7c1563a2d175bffa61258f6f1aac89dd103c0 Mon Sep 17 00:00:00 2001 From: AeBoPi <109503402+AeBoPi@users.noreply.github.com> Date: Thu, 28 May 2026 10:51:56 +0800 Subject: [PATCH 6/8] Reduce vocab rating partial refresh area --- backend/core/json_renderer.py | 10 ++- backend/core/modes/builtin/vocab_review.json | 6 +- firmware/src/main.cpp | 74 +++++++++++++++++++- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/backend/core/json_renderer.py b/backend/core/json_renderer.py index 683a4933..6ff07e8f 100644 --- a/backend/core/json_renderer.py +++ b/backend/core/json_renderer.py @@ -1776,6 +1776,7 @@ def _render_rating_choices(ctx: RenderContext, block: dict) -> None: height = int(block.get("height", 24) * ctx.scale) outline_width = max(1, int(block.get("line_width", 1) * ctx.scale)) margin_bottom = int(block.get("margin_bottom", 6) * ctx.scale) + selected_style = str(block.get("selected_style", "fill")).lower() count = len(labels) total_w = max(20, ctx.available_width - margin_x * 2) @@ -1789,7 +1790,7 @@ def _render_rating_choices(ctx: RenderContext, block: dict) -> None: x1 = x0 + chip_w y1 = y + height is_selected = i == selected - if is_selected: + if is_selected and selected_style != "cursor": ctx.draw.rectangle([x0, y, x1, y1], fill=EINK_FG) text_fill = EINK_BG else: @@ -1802,6 +1803,13 @@ def _render_rating_choices(ctx: RenderContext, block: dict) -> None: tx = x0 + (chip_w - text_w) // 2 - bbox[0] ty = y + (height - text_h) // 2 - bbox[1] ctx.draw.text((tx, ty), label, fill=text_fill, font=font) + if is_selected and selected_style == "cursor": + marker_w = max(6, int(block.get("cursor_width", 12) * ctx.scale)) + marker_h = max(2, int(block.get("cursor_height", 3) * ctx.scale)) + marker_gap = max(1, int(block.get("cursor_gap", 2) * ctx.scale)) + mx0 = x0 + (chip_w - marker_w) // 2 + my0 = max(y + 1, y1 - marker_h - marker_gap) + ctx.draw.rectangle([mx0, my0, mx0 + marker_w, my0 + marker_h], fill=EINK_FG) ctx.y = y + height + margin_bottom diff --git a/backend/core/modes/builtin/vocab_review.json b/backend/core/modes/builtin/vocab_review.json index b54da3c9..d5eddc76 100644 --- a/backend/core/modes/builtin/vocab_review.json +++ b/backend/core/modes/builtin/vocab_review.json @@ -54,7 +54,7 @@ {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 16, "align": "center", "margin_x": 32, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 12, "align": "center", "margin_x": 38, "max_lines": 2}, {"type": "spacer", "height": 4}, - {"type": "rating_choices", "selected_field": "rating_cursor", "labels": ["忘了", "模糊", "记住"], "font": "noto_serif_bold", "font_size": 14, "height": 26, "margin_x": 28, "gap": 10, "margin_bottom": 4}, + {"type": "rating_choices", "selected_field": "rating_cursor", "labels": ["忘了", "模糊", "记住"], "font": "noto_serif_bold", "font_size": 14, "height": 26, "margin_x": 28, "gap": 10, "margin_bottom": 4, "selected_style": "cursor", "cursor_width": 12, "cursor_height": 3}, {"type": "text", "field": "rating_hint", "font": "noto_serif_light", "font_size": 10, "align": "center", "margin_x": 20, "max_lines": 1} ] }, @@ -83,7 +83,7 @@ {"type": "spacer", "height": 3}, {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 13, "line_height": 16, "reserve_line_height": true, "align": "center", "margin_x": 8, "max_lines": 1}, {"type": "spacer", "height": 2}, - {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 30, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 12, "align": "center", "margin_x": 10, "max_lines": 1}, {"type": "rating_choices", "selected_field": "rating_cursor", "labels": ["忘", "糊", "记"], "font": "noto_serif_bold", "font_size": 10, "height": 16, "margin_x": 18, "gap": 5, "margin_bottom": 0}]}], "fallback_children": [{"type": "spacer", "height": 8}]} + {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 30, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 12, "align": "center", "margin_x": 10, "max_lines": 1}, {"type": "rating_choices", "selected_field": "rating_cursor", "labels": ["忘", "糊", "记"], "font": "noto_serif_bold", "font_size": 10, "height": 16, "margin_x": 18, "gap": 5, "margin_bottom": 0, "selected_style": "cursor", "cursor_width": 8, "cursor_height": 2}]}], "fallback_children": [{"type": "spacer", "height": 8}]} ], "footer": {"label": "VOCAB", "height": 18, "attribution_template": "{progress}"} }, @@ -94,7 +94,7 @@ {"type": "spacer", "height": 12}, {"type": "text", "field": "phonetic", "font": "noto_serif_light", "font_name": "GentiumPlus-Regular.ttf", "font_size": 14, "line_height": 20, "reserve_line_height": true, "align": "center", "margin_x": 30, "max_lines": 1}, {"type": "spacer", "height": 10}, - {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 44, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 20, "align": "center", "margin_x": 52, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 14, "align": "center", "margin_x": 70, "max_lines": 2}, {"type": "rating_choices", "selected_field": "rating_cursor", "labels": ["忘了", "模糊", "记住"], "font": "noto_serif_bold", "font_size": 20, "height": 34, "margin_x": 74, "gap": 16, "margin_bottom": 0}]}], "fallback_children": [{"type": "spacer", "height": 44}]} + {"type": "conditional", "field": "state", "conditions": [{"op": "eq", "value": "back", "children": [{"type": "separator", "style": "short", "width": 44, "line_width": 1}, {"type": "text", "field": "definition", "font": "noto_serif_regular", "font_size": 20, "align": "center", "margin_x": 52, "max_lines": 2}, {"type": "text", "field": "example", "font": "noto_serif_light", "font_size": 14, "align": "center", "margin_x": 70, "max_lines": 2}, {"type": "rating_choices", "selected_field": "rating_cursor", "labels": ["忘了", "模糊", "记住"], "font": "noto_serif_bold", "font_size": 20, "height": 34, "margin_x": 74, "gap": 16, "margin_bottom": 0, "selected_style": "cursor", "cursor_width": 16, "cursor_height": 4}]}], "fallback_children": [{"type": "spacer", "height": 44}]} ] } } diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 6630cf7b..1c03e9e3 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -185,6 +185,78 @@ static void copyVocabRegionToImage(const uint8_t *part) { } } +static bool displayVocabDiffRegion(const uint8_t *newPart, const uint8_t *oldPart) { + if (!newPart || !oldPart) return false; + int regionH = vocabRegionYEnd - vocabRegionYStart; + int minByte = ROW_BYTES; + int maxByte = -1; + int minRow = regionH; + int maxRow = -1; + + for (int row = 0; row < regionH; row++) { + const uint8_t *newRow = newPart + row * ROW_BYTES; + const uint8_t *oldRow = oldPart + row * ROW_BYTES; + for (int byteX = 0; byteX < ROW_BYTES; byteX++) { + if (newRow[byteX] == oldRow[byteX]) continue; + if (byteX < minByte) minByte = byteX; + if (byteX > maxByte) maxByte = byteX; + if (row < minRow) minRow = row; + if (row > maxRow) maxRow = row; + } + } + + if (maxByte < minByte || maxRow < minRow) return true; + + int xByte0 = max(0, minByte - 1); + int xByte1 = min(ROW_BYTES, maxByte + 2); + int y0 = max(0, minRow - 2); + int y1 = min(regionH, maxRow + 3); + int widthBytes = xByte1 - xByte0; + int height = y1 - y0; + int refreshX0 = xByte0 * 8; + int refreshY0 = vocabRegionYStart + y0; + int refreshX1 = xByte1 * 8; + int refreshY1 = vocabRegionYStart + y1; + int refreshW = refreshX1 - refreshX0; + int refreshH = refreshY1 - refreshY0; + int areaPctX100 = (refreshW * refreshH * 10000) / max(1, W * H); + Serial.printf( + "[VOCAB] rating partial rect x=%d y=%d w=%d h=%d area=%d.%02d%% mode=with-old\n", + refreshX0, + refreshY0, + refreshW, + refreshH, + areaPctX100 / 100, + areaPctX100 % 100 + ); + size_t patchLen = (size_t)widthBytes * (size_t)height; + uint8_t *newPatch = (uint8_t *)malloc(patchLen); + uint8_t *oldPatch = (uint8_t *)malloc(patchLen); + if (!newPatch || !oldPatch) { + if (newPatch) free(newPatch); + if (oldPatch) free(oldPatch); + epdPartialDisplayWithOld((uint8_t *)newPart, oldPart, 0, vocabRegionYStart, W, vocabRegionYEnd); + return true; + } + + for (int row = 0; row < height; row++) { + memcpy(newPatch + row * widthBytes, newPart + (y0 + row) * ROW_BYTES + xByte0, widthBytes); + memcpy(oldPatch + row * widthBytes, oldPart + (y0 + row) * ROW_BYTES + xByte0, widthBytes); + } + + epdPartialDisplayWithOld( + newPatch, + oldPatch, + refreshX0, + refreshY0, + refreshX1, + refreshY1 + ); + free(newPatch); + free(oldPatch); + return true; +} + static bool displayCachedVocabRating(int cursor) { if (!vocabRatingParts || vocabRatingPartLen == 0 || !epdSupportsPartialRefresh()) { return false; @@ -204,7 +276,7 @@ static bool displayCachedVocabRating(int cursor) { uint8_t *newPart = vocabRatingParts + vocabRatingPartLen * cursor; copyVocabRegionToImage(newPart); - epdPartialDisplayWithOld(newPart, oldPart, 0, vocabRegionYStart, W, vocabRegionYEnd); + displayVocabDiffRegion(newPart, oldPart); free(oldPart); return true; } From 54b63e96589854fbf8525c58366395d277e6cd6c Mon Sep 17 00:00:00 2001 From: AeBoPi <109503402+AeBoPi@users.noreply.github.com> Date: Thu, 28 May 2026 11:33:28 +0800 Subject: [PATCH 7/8] Improve calendar weather and timetable settings --- backend/core/context.py | 37 +++++- backend/core/json_content.py | 10 +- backend/core/json_renderer.py | 34 +++-- backend/core/modes/builtin/calendar.json | 2 +- .../app/device/[mac]/mode-settings.tsx | 125 ++++++++++++++++-- inksight-mobile/messages/en.json | 4 + inksight-mobile/messages/zh.json | 4 + webapp/app/config/page.tsx | 2 + webapp/app/preview/page.tsx | 2 + webapp/components/config/timetable-editor.tsx | 52 +++++++- 10 files changed, 238 insertions(+), 34 deletions(-) diff --git a/backend/core/context.py b/backend/core/context.py index fb2386a5..7d05d93b 100644 --- a/backend/core/context.py +++ b/backend/core/context.py @@ -1181,6 +1181,15 @@ async def get_weather_forecast( params = { "latitude": lat, "longitude": lon, + "current": ",".join( + [ + "temperature_2m", + "weather_code", + "relative_humidity_2m", + "wind_direction_10m", + "wind_speed_10m", + ] + ), # 预报字段:温度、天气代码、湿度、主导风向、风速、日出日落时间 "daily": ",".join( [ @@ -1204,6 +1213,7 @@ async def get_weather_forecast( else OPEN_METEO_URL ) data = await _fetch_weather_data(forecast_url, params) + current = data.get("current", {}) if isinstance(data.get("current"), dict) else {} daily = data.get("daily", {}) dates = daily.get("time", []) t_max = daily.get("temperature_2m_max", []) @@ -1268,9 +1278,11 @@ async def get_weather_forecast( today = full_forecast[0] if full_forecast else {} today_high = today.get("temp_max", "--") today_low = today.get("temp_min", "--") - today_temp = today_high # 大号数字使用最高温 - today_desc = today.get("desc", "") - today_code = today.get("code", -1) + current_temp = _safe_int(current.get("temperature_2m")) + current_code = _safe_int(current.get("weather_code")) + today_temp = str(current_temp) if current_temp is not None else today_high + today_code = current_code if current_code is not None else today.get("code", -1) + today_desc = _weather_code_to_desc(today_code, language=language) if today_low != "--" and today_high != "--": today_range = f"{today_low}°C / {today_high}°C" @@ -1279,7 +1291,10 @@ async def get_weather_forecast( # 今天的湿度 today_humidity = "--" - if humidities: + current_humidity = _safe_int(current.get("relative_humidity_2m")) + if current_humidity is not None: + today_humidity = str(current_humidity) + elif humidities: try: today_humidity = str(int(round(humidities[0]))) except (TypeError, ValueError): @@ -1300,17 +1315,25 @@ def _deg_to_wind_dir(deg: float) -> str: return "" today_wind_dir = "" - if wind_dirs: + current_wind_dir = current.get("wind_direction_10m") + if current_wind_dir is not None: + try: + today_wind_dir = _deg_to_wind_dir(float(current_wind_dir)) + except (TypeError, ValueError): + today_wind_dir = "" + elif wind_dirs: try: today_wind_dir = _deg_to_wind_dir(float(wind_dirs[0])) except (TypeError, ValueError): today_wind_dir = "" today_wind_level = "" - if wind_speeds: + current_wind_speed = current.get("wind_speed_10m") + wind_speed_for_level = current_wind_speed if current_wind_speed is not None else (wind_speeds[0] if wind_speeds else None) + if wind_speed_for_level is not None: try: # 这里使用风速近似为等级(粗略):m/s 四舍五入作为“几级” - level = max(1, min(12, int(round(float(wind_speeds[0]) / 2)))) # 简单映射 + level = max(1, min(12, int(round(float(wind_speed_for_level) / 2)))) # 简单映射 today_wind_level = f"Lv {level}" if language == "en" else f"{level}级" except (TypeError, ValueError): today_wind_level = "" diff --git a/backend/core/json_content.py b/backend/core/json_content.py index 01964482..3bf379cd 100644 --- a/backend/core/json_content.py +++ b/backend/core/json_content.py @@ -1383,8 +1383,14 @@ def _en_floating_holidays(y: int, m: int) -> dict[int, str]: weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] weekdays_short = ["一", "二", "三", "四", "五"] + custom_weekdays = mode_settings.get("weekdays") + if isinstance(custom_weekdays, list): + cleaned_weekdays = [str(item).strip() for item in custom_weekdays if str(item).strip()] + if cleaned_weekdays: + weekdays_short = cleaned_weekdays[:7] + wd = now.weekday() - current_day = wd if wd < 5 else -1 + current_day = wd if wd < len(weekdays_short) else -1 current_period = -1 for pi, p_label in enumerate(periods): @@ -1401,7 +1407,7 @@ def _en_floating_holidays(y: int, m: int) -> dict[int, str]: grid: list[list[str]] = [] for pi in range(len(periods)): row = [] - for di in range(5): + for di in range(len(weekdays_short)): row.append(str(courses.get(f"{di}-{pi}", ""))) grid.append(row) if is_en: diff --git a/backend/core/json_renderer.py b/backend/core/json_renderer.py index 6ff07e8f..4011471d 100644 --- a/backend/core/json_renderer.py +++ b/backend/core/json_renderer.py @@ -2870,10 +2870,10 @@ def _render_timetable_daily(ctx: RenderContext, block: dict) -> None: is_current = slot.get(current_field, False) loc = str(slot.get(location_field, "")) - if is_current and ctx.colors >= 3: + if is_current: ctx.draw.rectangle( [x0, ctx.y, x0 + grid_w, ctx.y + row_h - 1], - fill=highlight_color, + fill=highlight_color if ctx.colors >= 3 else EINK_FG, ) text_color = current_text_color else: @@ -2991,7 +2991,8 @@ def _render_timetable_weekly(ctx: RenderContext, block: dict) -> None: row_h = max(int(16 * ctx.scale), avail_h // max(n_periods, 1)) time_col_w = int(grid_w * time_col_ratio) - day_col_w = (grid_w - time_col_w) // 5 + day_count = max(1, len(weekdays)) + day_col_w = (grid_w - time_col_w) // day_count highlight_color = _resolve_named_color(ctx, block.get("highlight_color", "red"), EINK_FG) accent_color = _resolve_named_color(ctx, block.get("accent_color", "yellow"), EINK_FG) @@ -2999,12 +3000,24 @@ def _render_timetable_weekly(ctx: RenderContext, block: dict) -> None: show_location = bool(block.get("show_location", True)) hx = x0 + time_col_w - for di, wd_label in enumerate(weekdays[:5]): - cx = hx + di * day_col_w + day_col_w // 2 + for di, wd_label in enumerate(weekdays[:day_count]): + cell_x = hx + di * day_col_w + cx = cell_x + day_col_w // 2 bb = header_font.getbbox(wd_label) tw = bb[2] - bb[0] tx = cx - tw // 2 color = highlight_color if di == current_day else EINK_FG + if di == current_day and ctx.colors < 3: + ctx.draw.rectangle( + [ + cell_x + 1, + ctx.y, + cell_x + day_col_w - 2, + ctx.y + header_h - 1, + ], + fill=EINK_FG, + ) + color = EINK_BG ctx.draw.text((tx, ctx.y), wd_label, fill=color, font=header_font) ctx.y += header_h ctx.draw.line([(x0, ctx.y), (x0 + grid_w, ctx.y)], fill=EINK_FG, width=1) @@ -3044,23 +3057,24 @@ def _render_timetable_weekly(ctx: RenderContext, block: dict) -> None: row_data = grid[pi] if pi < len(grid) else [] - for di in range(5): + for di in range(day_count): cell_x = x0 + time_col_w + di * day_col_w cell_text = str(row_data[di]) if di < len(row_data) else "" is_current_cell = (di == current_day and pi == current_period) highlight_col = (not has_time_range and di == current_day) + highlight_today_course_bw = (ctx.colors < 3 and di == current_day and bool(cell_text)) - if is_current_cell and ctx.colors >= 3: + if is_current_cell or highlight_today_course_bw: ctx.draw.rectangle( [cell_x + 1, ctx.y, cell_x + day_col_w - 1, ctx.y + row_h - 1], - fill=highlight_color, + fill=highlight_color if ctx.colors >= 3 else EINK_FG, ) text_color = current_text_color - elif highlight_col and ctx.colors >= 3: + elif highlight_col: ctx.draw.rectangle( [cell_x + 1, ctx.y, cell_x + day_col_w - 1, ctx.y + row_h - 1], - fill=highlight_color, + fill=highlight_color if ctx.colors >= 3 else EINK_FG, ) text_color = current_text_color else: diff --git a/backend/core/modes/builtin/calendar.json b/backend/core/modes/builtin/calendar.json index c3f02896..38142e40 100644 --- a/backend/core/modes/builtin/calendar.json +++ b/backend/core/modes/builtin/calendar.json @@ -66,7 +66,7 @@ "type": "calendar_grid", "font_size": 16, "header_font_size": 11, - "sub_font_size": 7, + "sub_font_size": 9, "reminder_font": "noto_serif_light", "reminder_font_size": 9, "header_gap": 5, diff --git a/inksight-mobile/app/device/[mac]/mode-settings.tsx b/inksight-mobile/app/device/[mac]/mode-settings.tsx index e90eeadf..f020fed0 100644 --- a/inksight-mobile/app/device/[mac]/mode-settings.tsx +++ b/inksight-mobile/app/device/[mac]/mode-settings.tsx @@ -17,6 +17,7 @@ import { theme } from '@/lib/theme'; type CountdownEvent = { name: string; date: string; type?: string }; type Reminder = { month: string; day: string; text: string }; +type TimetableTemplate = 'university' | 'k12' | null; type VocabDeck = { id: string; labelKey: string; fallback: string }; const DEFAULT_PERIODS = ['08:00-09:30', '10:00-11:30', '14:00-15:30', '16:00-17:30']; @@ -27,7 +28,20 @@ const DEFAULT_COURSES: Record = { '3-1': '概率论/A201', '3-3': '毛概/D405', '4-0': '操作系统/C102', }; -const WEEKDAYS = 5; +const K12_PERIODS = ['第1节', '第2节', '第3节', '第4节', '第5节', '第6节', '第7节', '第8节']; +const K12_COURSES: Record = { + '0-0': '语文', '0-1': '数学', '0-2': '英语', '0-3': '物理', + '0-4': '化学', '0-5': '生物', '0-6': '历史', '0-7': '数学', + '1-0': '数学', '1-1': '语文', '1-2': '物理', '1-3': '化学', + '1-4': '英语', '1-5': '政治', '1-6': '地理', '1-7': '语文', + '2-0': '英语', '2-1': '物理', '2-2': '数学', '2-3': '语文', + '2-4': '生物', '2-5': '化学', '2-6': '政治', '2-7': '物理', + '3-0': '物理', '3-1': '化学', '3-2': '语文', '3-3': '数学', + '3-4': '历史', '3-5': '地理', '3-6': '英语', '3-7': '化学', + '4-0': '化学', '4-1': '英语', '4-2': '生物', '4-3': '历史', + '4-4': '语文', '4-5': '数学', '4-6': '地理', '4-7': '英语', +}; +const DEFAULT_WEEKDAY_COUNT = 5; const DEFAULT_VOCAB_DECK_ID = 'primary_en'; const DEFAULT_VOCAB_DAILY_LIMIT = 30; const VOCAB_DECKS: VocabDeck[] = [ @@ -47,6 +61,16 @@ function clampVocabDailyLimit(raw: string) { return Math.max(1, Math.min(200, parsed)); } +function detectTimetableTemplate(periods: string[]): TimetableTemplate { + if (periods.length === K12_PERIODS.length && periods.every((p, i) => p === K12_PERIODS[i])) { + return 'k12'; + } + if (periods.length === DEFAULT_PERIODS.length && periods.every((p, i) => p === DEFAULT_PERIODS[i])) { + return 'university'; + } + return null; +} + export default function ModeSettingsScreen() { const { locale, t } = useI18n(); const params = useLocalSearchParams<{ mac: string; mode: string }>(); @@ -83,6 +107,8 @@ export default function ModeSettingsScreen() { // --- TIMETABLE --- const [ttStyle, setTtStyle] = useState<'daily' | 'weekly'>('weekly'); + const [timetableTemplate, setTimetableTemplate] = useState('university'); + const [weekdays, setWeekdays] = useState([]); const [periods, setPeriods] = useState([...DEFAULT_PERIODS]); const [courseGrid, setCourseGrid] = useState>({ ...DEFAULT_COURSES }); @@ -126,8 +152,11 @@ export default function ModeSettingsScreen() { const c = (ov.courses && typeof ov.courses === 'object' && !Array.isArray(ov.courses)) ? ov.courses as Record : {}; - if (p.length > 0 || Object.keys(c).length > 0) { + const wd = Array.isArray(ov.weekdays) ? ov.weekdays.map(String).filter((d) => d.trim()) : []; + if (p.length > 0 || Object.keys(c).length > 0 || wd.length > 0) { setTtStyle(ov.style === 'weekly' ? 'weekly' : 'daily'); + setTimetableTemplate(detectTimetableTemplate(p)); + setWeekdays(wd); setPeriods(p); setCourseGrid(c); } @@ -152,6 +181,15 @@ export default function ModeSettingsScreen() { } }, [configQuery.data, modeId]); + function getDefaultWeekdays() { + return Array.from({ length: DEFAULT_WEEKDAY_COUNT }, (_, i) => t(`ms.day${i}`)); + } + + function getEffectiveWeekdays() { + const trimmed = weekdays.map((d) => d.trim()).filter(Boolean); + return trimmed.length > 0 ? trimmed : getDefaultWeekdays(); + } + function buildOverride(): Record { const base: Record = {}; if (modeId === 'WEATHER') { @@ -172,6 +210,7 @@ export default function ModeSettingsScreen() { base.reminders = rem; } else if (modeId === 'TIMETABLE') { base.style = ttStyle; + base.weekdays = getEffectiveWeekdays(); base.periods = periods.filter((p) => p.trim()); const c: Record = {}; for (const [k, v] of Object.entries(courseGrid)) { @@ -408,26 +447,52 @@ export default function ModeSettingsScreen() { ); } - function loadTimetableTemplate() { - setPeriods([...DEFAULT_PERIODS]); - setCourseGrid({ ...DEFAULT_COURSES }); + function loadTimetableTemplate(template: 'university' | 'k12') { + setTimetableTemplate(template); + setWeekdays([]); + setPeriods(template === 'k12' ? [...K12_PERIODS] : [...DEFAULT_PERIODS]); + setCourseGrid(template === 'k12' ? { ...K12_COURSES } : { ...DEFAULT_COURSES }); setTtStyle('weekly'); } function renderTimetable() { - const allDayLabels = Array.from({ length: WEEKDAYS }, (_, i) => t(`ms.day${i}`)); + const allDayLabels = getEffectiveWeekdays(); const todayIdx = new Date().getDay(); const todayDayIdx = todayIdx === 0 ? 6 : todayIdx - 1; const visibleDays = ttStyle === 'weekly' - ? Array.from({ length: WEEKDAYS }, (_, i) => i) - : [Math.min(todayDayIdx, WEEKDAYS - 1)]; + ? allDayLabels.map((_, i) => i) + : [Math.min(todayDayIdx, allDayLabels.length - 1)]; + + const addWeekday = () => { + const next = getEffectiveWeekdays(); + const n = next.length; + const label = n < 7 ? t(`ms.day${n}`) : `${t('ms.weekdays')} ${n + 1}`; + setWeekdays([...next, label]); + }; + + const removeWeekday = (idx: number) => { + if (allDayLabels.length <= 1) return; + const nextLabels = allDayLabels.filter((_, i) => i !== idx); + setWeekdays(nextLabels); + setCourseGrid((prev) => { + const next: Record = {}; + for (const [key, value] of Object.entries(prev)) { + const [diRaw, piRaw] = key.split('-'); + const di = parseInt(diRaw ?? '', 10); + const pi = parseInt(piRaw ?? '', 10); + if (Number.isNaN(di) || Number.isNaN(pi) || di === idx) continue; + const newDi = di > idx ? di - 1 : di; + next[`${newDi}-${pi}`] = value; + } + return next; + }); + }; return ( <> {t('ms.timetableStyle')} - setTtStyle('weekly')} /> + + {t('ms.loadTemplate')} + + loadTimetableTemplate('university')} + /> + loadTimetableTemplate('k12')} + /> + + + + + + {t('ms.weekdays')} + {allDayLabels.map((day, i) => ( + + { + const copy = [...allDayLabels]; + copy[i] = v; + setWeekdays(copy); + }} + placeholder={t(`ms.day${Math.min(i, 6)}`)} + placeholderTextColor={theme.colors.tertiary} + /> + removeWeekday(i)} disabled={allDayLabels.length <= 1}> + {t('ms.remove')} + + + ))} + diff --git a/inksight-mobile/messages/en.json b/inksight-mobile/messages/en.json index 2e4d3969..117b0197 100644 --- a/inksight-mobile/messages/en.json +++ b/inksight-mobile/messages/en.json @@ -332,10 +332,14 @@ "ms.reminderText": "Reminder text", "ms.addReminder": "Add reminder", "ms.loadTemplate": "Load template", + "ms.templateUniversity": "University", + "ms.templateK12": "K-12", "ms.timetableStyle": "Display style", "ms.timetableStyleHint": "Daily: e-ink shows today only; Weekly: shows the full week", "ms.timetableStyleDaily": "Daily", "ms.timetableStyleWeekly": "Weekly", + "ms.weekdays": "Day columns", + "ms.addColumn": "Add column", "ms.periods": "Periods", "ms.periodPlaceholder": "e.g. 08:00-09:30", "ms.addPeriod": "Add period", diff --git a/inksight-mobile/messages/zh.json b/inksight-mobile/messages/zh.json index 97a63379..046c96ef 100644 --- a/inksight-mobile/messages/zh.json +++ b/inksight-mobile/messages/zh.json @@ -332,10 +332,14 @@ "ms.reminderText": "提醒内容", "ms.addReminder": "添加提醒", "ms.loadTemplate": "加载模板", + "ms.templateUniversity": "大学", + "ms.templateK12": "中小学", "ms.timetableStyle": "显示样式", "ms.timetableStyleHint": "每日:墨水屏只显示当天课程;每周:显示整周课程表", "ms.timetableStyleDaily": "每日", "ms.timetableStyleWeekly": "每周", + "ms.weekdays": "星期列", + "ms.addColumn": "添加列", "ms.periods": "时间段", "ms.periodPlaceholder": "如 08:00-09:30", "ms.addPeriod": "添加时间段", diff --git a/webapp/app/config/page.tsx b/webapp/app/config/page.tsx index e2a5aaf5..b76d4382 100644 --- a/webapp/app/config/page.tsx +++ b/webapp/app/config/page.tsx @@ -1243,6 +1243,7 @@ function ConfigPageInner() { if (existing.periods && existing.courses) { setTimetableData({ style: (existing.style as "daily" | "weekly") || "daily", + weekdays: Array.isArray(existing.weekdays) ? existing.weekdays as string[] : undefined, periods: existing.periods as string[], courses: existing.courses as Record, }); @@ -4134,6 +4135,7 @@ function ConfigPageInner() { onClick={() => { commitModalAction(paramModal.mode, paramModal.action, { style: timetableData.style, + weekdays: timetableData.weekdays, periods: timetableData.periods, courses: timetableData.courses, } as ModeOverride); diff --git a/webapp/app/preview/page.tsx b/webapp/app/preview/page.tsx index 8817bf52..8be9c0ef 100644 --- a/webapp/app/preview/page.tsx +++ b/webapp/app/preview/page.tsx @@ -346,6 +346,7 @@ export default function ExperiencePage() { } if (targetMode.toUpperCase() === "TIMETABLE" && !override) { mergedOverride.style = timetableData.style; + mergedOverride.weekdays = timetableData.weekdays; mergedOverride.periods = timetableData.periods; mergedOverride.courses = timetableData.courses; } @@ -1278,6 +1279,7 @@ export default function ExperiencePage() { setModal(null); await handlePreview(modal.modeId, { style: timetableData.style, + weekdays: timetableData.weekdays, periods: timetableData.periods, courses: timetableData.courses, }); diff --git a/webapp/components/config/timetable-editor.tsx b/webapp/components/config/timetable-editor.tsx index 6537871c..89c2263b 100644 --- a/webapp/components/config/timetable-editor.tsx +++ b/webapp/components/config/timetable-editor.tsx @@ -6,6 +6,7 @@ import { Plus, Minus, RotateCcw } from "lucide-react"; export interface TimetableData { style: "daily" | "weekly"; + weekdays?: string[]; periods: string[]; courses: Record; } @@ -89,7 +90,8 @@ export function TimetableEditor({ data, onChange, tr }: TimetableEditorProps) { const [draft, setDraft] = useState(""); const isEn = tr("zh", "en") === "en"; - const weekdays = isEn ? WEEKDAYS_EN : WEEKDAYS_ZH; + const defaultWeekdays = isEn ? WEEKDAYS_EN : WEEKDAYS_ZH; + const weekdays = (data.weekdays && data.weekdays.length > 0) ? data.weekdays : defaultWeekdays; const templateType = useMemo(() => detectTemplate(data), [data]); const getTemplate = useCallback((t: TemplateType) => { @@ -117,6 +119,12 @@ export function TimetableEditor({ data, onChange, tr }: TimetableEditorProps) { onChange({ ...data, periods: next }); }, [data, onChange]); + const setWeekdayLabel = useCallback((idx: number, value: string) => { + const next = [...weekdays]; + next[idx] = value; + onChange({ ...data, weekdays: next }); + }, [data, onChange, weekdays]); + const addPeriod = useCallback(() => { const n = data.periods.length + 1; const label = templateType === "k12" ? (isEn ? `P${n}` : `第${n}节`) : `${8 + (n - 1) * 2}:00`; @@ -127,11 +135,27 @@ export function TimetableEditor({ data, onChange, tr }: TimetableEditorProps) { if (data.periods.length <= 1) return; const pi = data.periods.length - 1; const next = { ...data.courses }; - for (let di = 0; di < 5; di++) { + for (let di = 0; di < weekdays.length; di++) { delete next[`${di}-${pi}`]; } onChange({ ...data, periods: data.periods.slice(0, -1), courses: next }); - }, [data, onChange]); + }, [data, onChange, weekdays.length]); + + const addWeekday = useCallback(() => { + const n = weekdays.length; + const label = defaultWeekdays[n] || (isEn ? `Day ${n + 1}` : `列${n + 1}`); + onChange({ ...data, weekdays: [...weekdays, label] }); + }, [data, defaultWeekdays, isEn, onChange, weekdays]); + + const removeWeekday = useCallback(() => { + if (weekdays.length <= 1) return; + const di = weekdays.length - 1; + const next = { ...data.courses }; + for (let pi = 0; pi < data.periods.length; pi++) { + delete next[`${di}-${pi}`]; + } + onChange({ ...data, weekdays: weekdays.slice(0, -1), courses: next }); + }, [data, onChange, weekdays]); const resetTemplate = useCallback(() => { onChange(getTemplate(templateType)); @@ -189,7 +213,11 @@ export function TimetableEditor({ data, onChange, tr }: TimetableEditorProps) { {weekdays.map((wd, i) => ( - {wd} + setWeekdayLabel(i, e.target.value)} + className="w-full min-w-[42px] bg-transparent text-center text-xs font-semibold outline-none" + /> ))} @@ -265,11 +293,15 @@ export function TimetableEditor({ data, onChange, tr }: TimetableEditorProps) { {/* Controls */} -
+
+ + - +
+ {[80, 90, 100, 120].map((years) => ( + + ))}
@@ -4189,7 +4197,7 @@ function ConfigPageInner() { - +
+ {[80, 90, 100, 120].map((years) => ( + + ))}