Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand All @@ -268,4 +275,4 @@ docs/mobile-app-design.md

inksight_tech/
lab/
.cursor/
.cursor/
192 changes: 192 additions & 0 deletions backend/api/routes/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import io
import json
import os
import struct
from datetime import datetime, timedelta
from typing import Optional

Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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("<HHHHIIBBBB", w, h, ys, ye, len(front_raw), part_len, len(parts), 0, 0, 0)
body += front_raw
for part in parts:
body += part

await update_device_state(
mac,
pending_mode="",
pending_refresh=0,
last_persona=VOCAB_MODE_ID,
last_refresh_at=datetime.now().isoformat(),
)
return Response(content=bytes(body), media_type="application/octet-stream")


@router.post("/device/{mac}/heartbeat", response_model=OkResponse)
async def post_device_heartbeat(
mac: str,
Expand Down
4 changes: 3 additions & 1 deletion backend/api/routes/mobile.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ async def get_widget_data(
content = _fallback_content(selected_mode, city)
updated_at = datetime.now().isoformat()

# Strip _prefetched_* binary blobs from content before JSON serialization
clean_content = {k: v for k, v in content.items() if not k.startswith("_prefetched_")}
info = get_registry().get_mode_info(selected_mode)
return {
"mac": mac.upper(),
Expand All @@ -297,5 +299,5 @@ async def get_widget_data(
"icon": info.icon if info else "star",
"updated_at": updated_at,
"preview_url": _preview_url(selected_mode, mac=mac.upper(), city=(config or {}).get("city")),
"content": content,
"content": clean_content,
}
1 change: 1 addition & 0 deletions backend/api/routes/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ async def render(
headers: dict[str, str] = {
"X-Render-Time-Ms": str(elapsed_ms),
"X-Cache-Hit": "1" if cache_hit else "0",
"X-Mode-Id": resolved_persona,
}
if configured_refresh_minutes is not None:
headers["X-Refresh-Minutes"] = str(configured_refresh_minutes)
Expand Down
5 changes: 4 additions & 1 deletion backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@
DEFAULT_LANGUAGE = "zh"
DEFAULT_MODE_LANGUAGE = "" # empty = follow webapp language setting
DEFAULT_CONTENT_TONE = "neutral"
DEFAULT_MODES = ["STOIC"]
DEFAULT_MODES = ["DAILY"]
DEFAULT_REFRESH_STRATEGY = "random"
DEFAULT_REFRESH_INTERVAL = 60 # minutes

Expand All @@ -385,6 +385,9 @@
"POETRY", "COUNTDOWN",
"ALMANAC", "LETTER", "THISDAY", "RIDDLE",
"QUESTION", "BIAS", "STORY", "LIFEBAR", "CHALLENGE",
"HABIT", "MEMO", "CALENDAR", "TIMETABLE",
"WORD_OF_THE_DAY", "VOCAB_REVIEW",
"MY_ADAPTIVE", "MY_QUOTE",
}


Expand Down
6 changes: 6 additions & 0 deletions backend/core/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,12 @@ async def init_db():
await _migrate_legacy_user_devices(db)
await _fix_duplicate_owners(db)
await db.commit()
try:
from .vocab_store import seed_builtin_vocab

await seed_builtin_vocab()
except Exception:
logger.warning("[VOCAB] Failed to seed builtin vocabulary", exc_info=True)


# ── User system ─────────────────────────────────────────────
Expand Down
37 changes: 30 additions & 7 deletions backend/core/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,15 @@ async def get_weather_forecast(
params = {
"latitude": lat,
"longitude": lon,
"current": ",".join(
[
"temperature_2m",
"weather_code",
"relative_humidity_2m",
"wind_direction_10m",
"wind_speed_10m",
]
),
# 预报字段:温度、天气代码、湿度、主导风向、风速、日出日落时间
"daily": ",".join(
[
Expand All @@ -1204,6 +1213,7 @@ async def get_weather_forecast(
else OPEN_METEO_URL
)
data = await _fetch_weather_data(forecast_url, params)
current = data.get("current", {}) if isinstance(data.get("current"), dict) else {}
daily = data.get("daily", {})
dates = daily.get("time", [])
t_max = daily.get("temperature_2m_max", [])
Expand Down Expand Up @@ -1268,9 +1278,11 @@ async def get_weather_forecast(
today = full_forecast[0] if full_forecast else {}
today_high = today.get("temp_max", "--")
today_low = today.get("temp_min", "--")
today_temp = today_high # 大号数字使用最高温
today_desc = today.get("desc", "")
today_code = today.get("code", -1)
current_temp = _safe_int(current.get("temperature_2m"))
current_code = _safe_int(current.get("weather_code"))
today_temp = str(current_temp) if current_temp is not None else today_high
today_code = current_code if current_code is not None else today.get("code", -1)
today_desc = _weather_code_to_desc(today_code, language=language)

if today_low != "--" and today_high != "--":
today_range = f"{today_low}°C / {today_high}°C"
Expand All @@ -1279,7 +1291,10 @@ async def get_weather_forecast(

# 今天的湿度
today_humidity = "--"
if humidities:
current_humidity = _safe_int(current.get("relative_humidity_2m"))
if current_humidity is not None:
today_humidity = str(current_humidity)
elif humidities:
try:
today_humidity = str(int(round(humidities[0])))
except (TypeError, ValueError):
Expand All @@ -1300,17 +1315,25 @@ def _deg_to_wind_dir(deg: float) -> str:
return ""

today_wind_dir = ""
if wind_dirs:
current_wind_dir = current.get("wind_direction_10m")
if current_wind_dir is not None:
try:
today_wind_dir = _deg_to_wind_dir(float(current_wind_dir))
except (TypeError, ValueError):
today_wind_dir = ""
elif wind_dirs:
try:
today_wind_dir = _deg_to_wind_dir(float(wind_dirs[0]))
except (TypeError, ValueError):
today_wind_dir = ""

today_wind_level = ""
if wind_speeds:
current_wind_speed = current.get("wind_speed_10m")
wind_speed_for_level = current_wind_speed if current_wind_speed is not None else (wind_speeds[0] if wind_speeds else None)
if wind_speed_for_level is not None:
try:
# 这里使用风速近似为等级(粗略):m/s 四舍五入作为“几级”
level = max(1, min(12, int(round(float(wind_speeds[0]) / 2)))) # 简单映射
level = max(1, min(12, int(round(float(wind_speed_for_level) / 2)))) # 简单映射
today_wind_level = f"Lv {level}" if language == "en" else f"{level}级"
except (TypeError, ValueError):
today_wind_level = ""
Expand Down
Loading
Loading