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 582a6541..b1c8dfbe 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, @@ -35,6 +37,7 @@ update_device_state, validate_alert_token, ) +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 @@ -56,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, @@ -138,6 +157,179 @@ 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_mode=VOCAB_MODE_ID, pending_refresh=1) + 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) + + 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) + 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") + + +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(" 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 d8794896..3bf379cd 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") @@ -1372,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): @@ -1390,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 3dc35efe..4011471d 100644 --- a/backend/core/json_renderer.py +++ b/backend/core/json_renderer.py @@ -1485,10 +1485,15 @@ def _render_text(ctx: RenderContext, block: dict) -> 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,66 @@ 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) + selected_style = str(block.get("selected_style", "fill")).lower() + + 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 and selected_style != "cursor": + 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) + 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 + + def _render_icon_text(ctx: RenderContext, block: dict) -> None: icon_name = block.get("icon") field_name = block.get("field") @@ -2796,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: @@ -2917,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) @@ -2925,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) @@ -2970,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: @@ -3019,6 +3107,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/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/backend/core/modes/builtin/vocab_review.json b/backend/core/modes/builtin/vocab_review.json new file mode 100644 index 00000000..d5eddc76 --- /dev/null +++ b/backend/core/modes/builtin/vocab_review.json @@ -0,0 +1,101 @@ +{ + "mode_id": "VOCAB_REVIEW", + "display_name": "背单词", + "icon": "book", + "cacheable": false, + "description": "单按键间隔重复背词卡片", + "settings_schema": [ + {"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", + "provider": "vocab_review", + "fallback": { + "state": "empty", + "word": "VOCAB", + "phonetic": "", + "definition": "暂无词卡", + "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, "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", + "field": "state", + "conditions": [ + { + "op": "eq", + "value": "back", + "children": [ + {"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": 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} + ] + }, + { + "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": 42} + ] + } + ], + "footer": {"label": "VOCAB", "attribution_template": "进度 {progress}"} + }, + "layout_overrides": { + "296x128": { + "body": [ + {"type": "spacer", "height": 4}, + {"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": "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}"} + }, + "648x480": { + "body": [ + {"type": "spacer", "height": 42}, + {"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": "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/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/backend/core/pipeline.py b/backend/core/pipeline.py index f1f1bb97..deb8584a 100644 --- a/backend/core/pipeline.py +++ b/backend/core/pipeline.py @@ -71,7 +71,12 @@ def get_effective_mode_config(cfg: dict | None, persona: str) -> dict: "llmProvider", "llmModel", } - mode_settings = {k: v for k, v in override.items() if k not in reserved} + explicit_mode_settings = override.get("mode_settings") + mode_settings = dict(explicit_mode_settings) if isinstance(explicit_mode_settings, dict) else {} + for k, v in override.items(): + if k in reserved or k == "mode_settings": + continue + mode_settings[k] = v if mode_settings: base["mode_settings"] = mode_settings return base diff --git a/backend/core/schemas.py b/backend/core/schemas.py index d9392f93..f5ce5feb 100644 --- a/backend/core/schemas.py +++ b/backend/core/schemas.py @@ -43,9 +43,9 @@ class ConfigRequest(BaseModel): mac: str = Field(..., description="设备 MAC 地址 (AA:BB:CC:DD:EE:FF)") nickname: str = Field(default="", max_length=32, description="设备昵称") modes: list[str] = Field( - default=["STOIC"], + default=["DAILY"], min_length=1, - max_length=10, + max_length=50, description="启用的内容模式列表", ) refreshStrategy: str = Field( 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..ace104cb --- /dev/null +++ b/backend/core/vocab_store.py @@ -0,0 +1,374 @@ +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_DIR = Path(__file__).resolve().parent / "vocab_data" + + +async def seed_builtin_vocab() -> None: + if not _DATA_DIR.exists(): + return + + now = datetime.now().isoformat() + db = await get_main_db() + for path in sorted(_DATA_DIR.glob("*.json")): + try: + items = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(items, list): + continue + 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) + 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) + 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_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"], + "phonetic": item.get("phonetic") or "", + "definition": item["definition"], + "example": item.get("example") or "", + "progress": f"{reviewed}/{daily_limit}", + "rating_label": RATING_LABELS[rating], + "rating_cursor": rating_cursor, + "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 RANDOM() + 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/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/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/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/backend/tests/test_unit_schemas.py b/backend/tests/test_unit_schemas.py index 2ce7d044..6221e4e7 100644 --- a/backend/tests/test_unit_schemas.py +++ b/backend/tests/test_unit_schemas.py @@ -83,7 +83,7 @@ def test_nickname_max_length(self): def test_defaults(self): body = ConfigRequest(mac="AA:BB:CC:DD:EE:FF") assert body.nickname == "" - assert body.modes == ["STOIC"] + assert body.modes == ["DAILY"] assert body.refreshStrategy == "random" assert body.refreshInterval == 60 assert body.always_active is False 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/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/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..5b154f80 --- /dev/null +++ b/docs/vocab-review.md @@ -0,0 +1,67 @@ +# 背单词模式 + +`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`:托福词汇 + +## 固件环境 + +背词专用固件环境: + +```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,默认 `primary_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..472bc498 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -111,6 +111,9 @@ 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 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 @@ -141,5 +144,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/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 b33aa571..1c03e9e3 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; @@ -92,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; @@ -105,6 +107,13 @@ struct DeviceContext { bool wantEnterLiveMode = false; bool wantEnterAiChatMode = false; bool wantSingleVoiceTurn = false; + bool wantEnterVocabReview = false; + bool wantVocabFlip = false; + bool wantVocabNextRating = false; + bool wantVocabSubmitRating = false; + bool wantVocabExit = false; + bool vocabReviewBackSide = false; + String currentRenderedModeId; String switchToModeId; }; @@ -122,8 +131,175 @@ 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 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; + } + 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); + displayVocabDiffRegion(newPart, oldPart); + 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); @@ -222,6 +398,82 @@ static void drainSendQueue(AudioService &as) { as.ReleaseSendPacket(pkt); } } + +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) { + 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; + } +} + +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; + } + + codec.EnableOutput(true); + if (!codec.outputEnabled()) { + Serial.println("[VOCAB] codec output enable failed"); + codec.Stop(); + return; + } + + VocabAudioPlaybackCtx playbackCtx; + playbackCtx.codec = &codec; + bool ok = fetchVocabAudio(vocabAudioChunkCallback, &playbackCtx); + if (!ok) { + Serial.println("[VOCAB] audio fetch failed"); + } + if (playbackCtx.hasPendingByte) { + playbackCtx.bytesDropped += 1; + } + 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 +} #endif // ═════════════════════════════════════════════════════════════ @@ -648,7 +900,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); @@ -787,6 +1039,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 +1131,105 @@ void loop() { postRuntimeMode("active"); } } +#if VOCAB_REVIEW_BUILD + } 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 (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; + 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"); + } + } + 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"); + } + } } else if (ctx.wantSingleVoiceTurn) { +#else + } else if (ctx.wantSingleVoiceTurn) { +#endif ctx.wantSingleVoiceTurn = false; ctx.switchToModeId = ""; Serial.println("[VOICE] Short press -> single voice turn"); @@ -1629,12 +1985,24 @@ 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"); + 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"); @@ -1644,6 +2012,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) { @@ -1667,13 +2041,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"); @@ -1685,6 +2066,7 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi) { bool exited = runAiChatConversation(); if (g_userAborted) { Serial.println("User aborted AI chat -> portal"); + if (previousImage) free(previousImage); enterPortalMode(); return; } @@ -1705,12 +2087,25 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi) { 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(); @@ -1723,10 +2118,12 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi) { } 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) { @@ -1736,7 +2133,9 @@ static void triggerImmediateRefresh(bool nextMode, bool keepWiFi) { } else { ledFeedback("fail"); Serial.println("WiFi reconnect failed"); + restorePreviousImage(); } + if (previousImage) free(previousImage); } static bool waitForContentReady() { @@ -1822,22 +2221,76 @@ 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(); - } else if (!ctx.wantEnterAiChatMode && - (millis() - ctx.aiBtnPressStart >= (unsigned long)AI_CHAT_BTN_HOLD_MS)) { - Serial.printf("[AI CHAT] Switch held for %dms, queue enter ai chat\n", AI_CHAT_BTN_HOLD_MS); - ctx.wantEnterAiChatMode = true; - ctx.aiBtnPressStart = 0; + } else if (!ctx.wantEnterAiChatMode) { + unsigned long holdTime = millis() - ctx.aiBtnPressStart; +#if VOCAB_REVIEW_BUILD + bool inVocabMode = ctx.currentRenderedModeId.equalsIgnoreCase(VOCAB_REVIEW_MODE_ID); + 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) { + 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 + } } } 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) + ? VOCAB_EXIT_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 && 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 { + 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..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); } @@ -933,6 +949,216 @@ 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 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; + 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(); + int contentLen = http.getSize(); + 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]; + 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; + } + 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 f2dfac81..4adc20b1 100644 --- a/firmware/src/network.h +++ b/firmware/src/network.h @@ -59,6 +59,10 @@ 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); // POST device config JSON to backend /api/config endpoint. void postConfigToBackend(); diff --git a/inksight-mobile/app/device/[mac].tsx b/inksight-mobile/app/device/[mac].tsx index 1cb0dbce..d24d7e60 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], @@ -99,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], @@ -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 = ['MY_QUOTE', 'HABIT', 'LIFEBAR', 'CALENDAR', 'TIMETABLE', 'MY_ADAPTIVE', 'VOCAB_REVIEW']; 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)} + /> + ); + })} + + + = { @@ -25,7 +36,48 @@ 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 = 'core_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)); +} + +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(); @@ -43,7 +95,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] ?? {}; @@ -53,7 +105,14 @@ export default function ModeSettingsScreen() { const [forecastDays, setForecastDays] = useState('3'); // --- MEMO --- - const [memoText, setMemoText] = useState(''); + const [memoDraft, setMemoDraft] = useState({ + title1: '', + text1: '', + title2: '', + text2: '', + title3: '', + text3: '', + }); // --- COUNTDOWN --- const [countdownEvents, setCountdownEvents] = useState([]); @@ -63,12 +122,33 @@ 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 }); // --- Generic schema fields --- const [schemaValues, setSchemaValues] = useState>({}); + // --- MY_ADAPTIVE --- + 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)); + + // --- MY_QUOTE --- + const [quoteText, setQuoteText] = useState(''); + const [quoteAuthor, setQuoteAuthor] = useState(''); + + // --- HABIT --- + const [habitItems, setHabitItems] = useState<{ name: string; done: boolean }[]>([]); + + // --- LIFEBAR --- + const [userAge, setUserAge] = useState('25'); + const [lifeExpectancy, setLifeExpectancy] = useState('80'); + useEffect(() => { if (!configQuery.data) return; const ov = configQuery.data.modeOverrides?.[modeId] ?? {}; @@ -77,7 +157,17 @@ export default function ModeSettingsScreen() { setCity(String(ov.city ?? '')); setForecastDays(String(ov.forecast_days ?? 3)); } else if (modeId === 'MEMO') { - setMemoText(String(ov.memo_text ?? '')); + const ms = (ov.mode_settings && typeof ov.mode_settings === 'object' && !Array.isArray(ov.mode_settings)) + ? ov.mode_settings as Record + : {}; + setMemoDraft({ + title1: String(ms.memo_title_1 ?? ov.memo_title_1 ?? ''), + text1: String(ms.memo_text_1 ?? ov.memo_text_1 ?? ov.memo_text ?? ''), + title2: String(ms.memo_title_2 ?? ov.memo_title_2 ?? ''), + text2: String(ms.memo_text_2 ?? ov.memo_text_2 ?? ''), + title3: String(ms.memo_title_3 ?? ov.memo_title_3 ?? ''), + text3: String(ms.memo_text_3 ?? ov.memo_text_3 ?? ''), + }); } else if (modeId === 'COUNTDOWN') { const evts = Array.isArray(ov.countdownEvents) ? ov.countdownEvents : []; setCountdownEvents(evts.map((e: Record) => ({ @@ -98,11 +188,38 @@ 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); } + } 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 if (modeId === 'VOCAB_REVIEW') { + setVocabDeckId(String(ov.deck_id || DEFAULT_VOCAB_DECK_ID)); + setVocabDailyLimit(String(ov.daily_limit || DEFAULT_VOCAB_DAILY_LIMIT)); + } else if (modeId === 'MY_QUOTE') { + setQuoteText(String(ov.quote ?? '')); + setQuoteAuthor(String(ov.author ?? '')); + } else if (modeId === 'HABIT') { + const items = Array.isArray(ov.habitItems) ? ov.habitItems : []; + setHabitItems(items.map((item: Record) => ({ + name: String(item.name ?? ''), + done: Boolean(item.done), + }))); + } else if (modeId === 'LIFEBAR') { + setUserAge(String(ov.age ?? '25')); + setLifeExpectancy(String(ov.life_expect ?? '80')); } else { const sv: Record = {}; for (const [k, v] of Object.entries(ov)) { @@ -112,6 +229,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') { @@ -119,7 +245,12 @@ export default function ModeSettingsScreen() { const fd = parseInt(forecastDays, 10); if (!isNaN(fd) && fd >= 1 && fd <= 7) base.forecast_days = fd; } else if (modeId === 'MEMO') { - base.memo_text = memoText; + for (const i of [1, 2, 3] as const) { + const titleKey = `title${i}` as keyof MemoDraft; + const textKey = `text${i}` as keyof MemoDraft; + base[`memo_title_${i}`] = memoDraft[titleKey].trim(); + base[`memo_text_${i}`] = memoDraft[textKey].trim(); + } } else if (modeId === 'COUNTDOWN') { base.countdownEvents = countdownEvents.filter((e) => e.name.trim() && e.date.trim()); } else if (modeId === 'CALENDAR') { @@ -132,12 +263,35 @@ 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)) { 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 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 if (modeId === 'MY_QUOTE') { + if (quoteText.trim()) base.quote = quoteText.trim(); + if (quoteAuthor.trim()) base.author = quoteAuthor.trim(); + } else if (modeId === 'HABIT') { + base.habitItems = habitItems.filter((item) => item.name.trim()); + } else if (modeId === 'LIFEBAR') { + const age = parseInt(userAge, 10); + const expect = parseInt(lifeExpectancy, 10); + if (!isNaN(age) && age > 0) base.age = age; + if (!isNaN(expect) && expect > 0) base.life_expect = expect; + if (!isNaN(age) && !isNaN(expect) && age > 0 && expect > 0) { + base.life_pct = Math.min(Math.round(age / expect * 1000) / 10, 100); + base.life_label = locale === 'en' ? 'Life' : '人生'; + } } else { for (const [k, v] of Object.entries(schemaValues)) { if (v.trim()) base[k] = v.trim(); @@ -177,6 +331,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 --- @@ -211,15 +398,35 @@ export default function ModeSettingsScreen() { return ( {t('ms.memoText')} - + {t('ms.memoHint')} + {([1, 2, 3] as const).map((i) => { + const titleKey = `title${i}` as keyof MemoDraft; + const textKey = `text${i}` as keyof MemoDraft; + return ( + + + {t('ms.memoTitle').replace('{n}', String(i))} + {i > 1 ? ` (${t('ms.optional')})` : ''} + + setMemoDraft((prev) => ({ ...prev, [titleKey]: v }))} + placeholder={i === 1 ? t('ms.memoTitlePlaceholder') : t('ms.memoOptionalTitlePlaceholder')} + placeholderTextColor={theme.colors.tertiary} + /> + setMemoDraft((prev) => ({ ...prev, [textKey]: v }))} + multiline + numberOfLines={4} + placeholder={t('ms.memoTextPlaceholder')} + placeholderTextColor={theme.colors.tertiary} + /> + + ); + })} ); } @@ -327,26 +534,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')} + + + ))} + @@ -451,7 +726,207 @@ 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')} + + )} + + )} + + + ); + } + + 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)); + }} + /> + + ); + } + + function renderMyQuote() { + return ( + + {t('ms.quoteHint')} + {t('ms.quoteText')} + + {t('ms.quoteAuthor')} + + + ); + } + + function renderHabit() { + return ( + + {t('ms.habitTitle')} + {t('ms.habitHint')} + {habitItems.map((item, i) => ( + + { + const copy = [...habitItems]; + copy[i] = { ...item, done: !item.done }; + setHabitItems(copy); + }} + style={[ + styles.habitDoneBtn, + item.done ? styles.habitDoneActive : styles.habitDoneInactive, + ]} + > + + {item.done ? '✓' : '○'} + + + { + const copy = [...habitItems]; + copy[i] = { ...item, name: v }; + setHabitItems(copy); + }} + placeholder={t('ms.habitNamePlaceholder')} + placeholderTextColor={theme.colors.tertiary} + /> + setHabitItems(habitItems.filter((_, idx) => idx !== i))}> + {t('ms.remove')} + + + ))} + setHabitItems([...habitItems, { name: '', done: false }])} + /> + + ); + } + + function renderLifebar() { + const age = parseInt(userAge, 10); + const expect = parseInt(lifeExpectancy, 10); + const pct = (!isNaN(age) && !isNaN(expect) && expect > 0) + ? Math.min(Math.round(age / expect * 1000) / 10, 100) + : null; + + return ( + + {t('ms.lifebarAge')} + setUserAge(v.replace(/\D/g, '').slice(0, 3))} + keyboardType="number-pad" + placeholder={t('ms.lifebarAgePlaceholder')} + placeholderTextColor={theme.colors.tertiary} + maxLength={3} + /> + {t('ms.lifebarLifeExpect')} + + {['80', '90', '100', '120'].map((val) => ( + setLifeExpectancy(val)} + /> + ))} + + {pct !== null && ( + + + {t('ms.lifebarPreview')}: {age}/{expect} · {pct}% + + + )} + + ); + } + + const hasCustomEditor = ['WEATHER', 'MEMO', 'COUNTDOWN', 'CALENDAR', 'TIMETABLE', 'MY_ADAPTIVE', 'VOCAB_REVIEW', 'MY_QUOTE', 'HABIT', 'LIFEBAR'].includes(modeId); return ( @@ -463,6 +938,11 @@ export default function ModeSettingsScreen() { {modeId === 'COUNTDOWN' && renderCountdown()} {modeId === 'CALENDAR' && renderCalendar()} {modeId === 'TIMETABLE' && renderTimetable()} + {modeId === 'MY_ADAPTIVE' && renderAdaptive()} + {modeId === 'VOCAB_REVIEW' && renderVocabReview()} + {modeId === 'MY_QUOTE' && renderMyQuote()} + {modeId === 'HABIT' && renderHabit()} + {modeId === 'LIFEBAR' && renderLifebar()} {!hasCustomEditor && (schema.length > 0 ? renderGenericSchema() : ( {t('device.modeSettingsNoSchema')} ))} @@ -499,10 +979,38 @@ const styles = StyleSheet.create({ textAlignVertical: 'top', paddingTop: 12, }, + memoTextarea: { + height: 88, + textAlignVertical: 'top', + paddingTop: 12, + }, + memoGroup: { + marginTop: 10, + }, + smallLabel: { + fontSize: 12, + marginBottom: 6, + }, row: { 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', @@ -582,4 +1090,90 @@ 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, + }, + habitDoneBtn: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + habitDoneActive: { + backgroundColor: theme.colors.ink, + }, + habitDoneInactive: { + backgroundColor: theme.colors.surface, + borderWidth: 1.5, + borderColor: theme.colors.border, + }, + habitDoneTextActive: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + habitDoneTextInactive: { + color: theme.colors.tertiary, + fontSize: 14, + }, }); diff --git a/inksight-mobile/features/device/api.ts b/inksight-mobile/features/device/api.ts index faf50a81..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; @@ -39,6 +40,7 @@ export type DeviceConfig = { llmProvider?: string; llmModel?: string; modeOverrides?: Record>; + screenSize?: string; }; export type DeviceMember = { @@ -277,6 +279,28 @@ 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 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 = JSON.parse(result.body || '{}') as { message?: string; error?: string }; + msg = payload.message || payload.error || msg; + } catch {} + throw new Error(msg); + } + const data = JSON.parse(result.body || '{}') 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/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 ef373805..e2fa8a5b 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.", @@ -315,7 +320,12 @@ "ms.cityPlaceholder": "Enter city name", "ms.forecastDays": "Forecast days", "ms.memoText": "Memo text", + "ms.memoHint": "Set memo titles and content. Up to 3 groups; empty groups are hidden.", + "ms.memoTitle": "Title {n}", + "ms.memoTitlePlaceholder": "e.g. Today's TODO", + "ms.memoOptionalTitlePlaceholder": "Optional title", "ms.memoTextPlaceholder": "Enter memo content", + "ms.optional": "optional", "ms.countdownEvents": "Countdown events", "ms.eventName": "Event name", "ms.eventDate": "Date (YYYY-MM-DD)", @@ -327,10 +337,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", @@ -344,6 +358,39 @@ "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", + "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", + "ms.quoteHint": "Set a custom quote and author to display on your device. Leave blank to use AI-generated quotes.", + "ms.quoteText": "Quote", + "ms.quoteTextPlaceholder": "Enter your favorite quote...", + "ms.quoteAuthor": "Author", + "ms.quoteAuthorPlaceholder": "e.g. Marcus Aurelius", + "ms.habitTitle": "Daily Habits", + "ms.habitHint": "Add habits you want to track each day. Tap the circle to mark as done.", + "ms.habitNamePlaceholder": "e.g. Exercise, Read, Meditate", + "ms.addHabit": "Add habit", + "ms.lifebarAge": "Your Age", + "ms.lifebarAgePlaceholder": "Enter your age", + "ms.lifebarLifeExpect": "Life Expectancy", + "ms.lifebarPreview": "Progress", "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..41210203 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": "请先登录后再查看组件预览数据。", @@ -315,7 +320,12 @@ "ms.cityPlaceholder": "输入城市名称", "ms.forecastDays": "预报天数", "ms.memoText": "便签文本", + "ms.memoHint": "设置便签标题和内容,最多 3 组,空的组不会显示。", + "ms.memoTitle": "标题 {n}", + "ms.memoTitlePlaceholder": "如:今日待办", + "ms.memoOptionalTitlePlaceholder": "可选标题", "ms.memoTextPlaceholder": "输入便签内容", + "ms.optional": "可选", "ms.countdownEvents": "倒计时事件", "ms.eventName": "事件名称", "ms.eventDate": "日期 (YYYY-MM-DD)", @@ -327,10 +337,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": "添加时间段", @@ -344,6 +358,39 @@ "ms.day5": "周六", "ms.day6": "周日", "ms.remove": "删除", + "ms.adaptiveTitle": "相框图片管理", + "ms.adaptiveHint": "上传至多 6 张图片,设备每次刷新时循环显示下一张。", + "ms.adaptiveAdd": "添加图片", + "ms.adaptiveRemove": "移除", + "ms.adaptiveUploading": "上传中...", + "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": "核心英语", + "ms.quoteHint": "设置自定义语录和作者,留空则使用 AI 随机生成。", + "ms.quoteText": "语录内容", + "ms.quoteTextPlaceholder": "输入你喜欢的语录...", + "ms.quoteAuthor": "作者", + "ms.quoteAuthorPlaceholder": "如:马可·奥勒留", + "ms.habitTitle": "每日打卡", + "ms.habitHint": "添加每天要追踪的习惯,点击圆圈标记完成。", + "ms.habitNamePlaceholder": "如:运动、阅读、冥想", + "ms.addHabit": "添加习惯", + "ms.lifebarAge": "你的年龄", + "ms.lifebarAgePlaceholder": "输入你的年龄", + "ms.lifebarLifeExpect": "预期寿命", + "ms.lifebarPreview": "进度预览", "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", diff --git a/webapp/app/config/page.tsx b/webapp/app/config/page.tsx index ab963937..bb3b4008 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; @@ -813,7 +813,9 @@ function ConfigPageInner() { : [{ name: "早起", done: false }, { name: "运动", done: false }, { name: "阅读", done: false }], ); const [userAge, setUserAge] = useState(30); - const [lifeExpectancy, setLifeExpectancy] = useState<100 | 120>(100); + const [lifeExpectancy, setLifeExpectancy] = useState(80); + const [vocabDeckId, setVocabDeckId] = useState("core_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") => { @@ -1197,7 +1199,12 @@ function ConfigPageInner() { return; } if (m === "MEMO") { - const ms = (modeOverrides[m]?.mode_settings || {}) as Record; + const savedMemo = (modeOverrides[m] || {}) as Record; + const ms = ( + savedMemo.mode_settings && typeof savedMemo.mode_settings === "object" && !Array.isArray(savedMemo.mode_settings) + ? savedMemo.mode_settings + : savedMemo + ) as Record; setMemoDraft({ title1: ms.memo_title_1 || "", text1: ms.memo_text_1 || "", @@ -1210,8 +1217,9 @@ function ConfigPageInner() { return; } if (m === "MY_QUOTE") { - setQuoteDraft(""); - setAuthorDraft(""); + const savedOv = (modeOverrides[m] || {}) as Record; + setQuoteDraft(typeof savedOv.quote === "string" ? savedOv.quote : ""); + setAuthorDraft(typeof savedOv.author === "string" ? savedOv.author : ""); setParamModal({ type: "quote", mode: m, action }); return; } @@ -1229,6 +1237,15 @@ function ConfigPageInner() { return; } if (m === "LIFEBAR") { + const savedOv = (modeOverrides[m] || {}) as Record; + const savedAge = Number(savedOv.age); + const savedExpect = Number(savedOv.life_expect); + if (Number.isFinite(savedAge) && savedAge > 0) { + setUserAge(savedAge); + } + if ([80, 90, 100, 120].includes(savedExpect)) { + setLifeExpectancy(savedExpect); + } setParamModal({ type: "lifebar", mode: m, action }); return; } @@ -1241,6 +1258,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, }); @@ -1248,7 +1266,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 || "core_en")); + setVocabDailyLimit(Number(ov.daily_limit || 30)); + setParamModal({ type: "vocab", mode: m, action }); + return; + } + }, [modeOverrides]); const clearModeOverride = useCallback((modeId: string) => { setModeOverrides((prev) => { @@ -2180,6 +2205,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 +3729,8 @@ function ConfigPageInner() { ? tr("日历提醒", "Calendar Reminders") : paramModal.type === "timetable" ? tr("课程表设置", "Timetable Settings") + : paramModal.type === "vocab" + ? tr("背单词设置", "Vocab Review Settings") : tr("人生进度条", "Life Progress")} - +
+ {[80, 90, 100, 120].map((years) => ( + + ))}
@@ -4048,6 +4143,7 @@ function ConfigPageInner() { onClick={() => { commitModalAction(paramModal.mode, paramModal.action, { style: timetableData.style, + weekdays: timetableData.weekdays, periods: timetableData.periods, courses: timetableData.courses, } as ModeOverride); @@ -4059,6 +4155,73 @@ function ConfigPageInner() { + ) : 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} diff --git a/webapp/app/preview/page.tsx b/webapp/app/preview/page.tsx index 8817bf52..156a0b24 100644 --- a/webapp/app/preview/page.tsx +++ b/webapp/app/preview/page.tsx @@ -165,7 +165,7 @@ export default function ExperiencePage() { // 人生进度条状态 const [userAge, setUserAge] = useState(30); - const [lifeExpectancy, setLifeExpectancy] = useState<100 | 120>(100); + const [lifeExpectancy, setLifeExpectancy] = useState(80); const [showCustomModeModal, setShowCustomModeModal] = useState(false); const [customDesc, setCustomDesc] = useState(""); @@ -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; } @@ -1158,27 +1159,20 @@ export default function ExperiencePage() { -
- - +
+ {[80, 90, 100, 120].map((years) => ( + + ))}
@@ -1278,6 +1272,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 */} -
+
+ +