Skip to content

Commit f981dae

Browse files
Lexonight1claude
andcommitted
refactor: eliminate hardcoded (320,320) resolution defaults
Resolution is now resolved exclusively from the USB handshake and stored per-device in config['devices']['X']['w'/'h']. FBL_PROFILES in core/models.py is the single source of truth for all resolution constants. Key changes: - settings._width/_height default to 0,0; _resolve_paths() skipped until a real resolution is known from the handshake - app._wire_bus() dispatches InitializeDeviceCommand after building the LCD bus, so CLI/API paths get settings.resolution set from the handshake (the GUI already did this via lcd_handler.apply_device_config) - cli/resume: calls lcd.set_resolution(*dev.resolution) before loading theme - ThemeService.discover_local/load_local_themes: resolution now optional (0,0) - services/media: set_target_size() required before load(); _make_service() fixture pre-wires it via FBL_PROFILES[100] - services/overlay: OverlayRenderer defaults to 0,0; tests use _ov() helper - test_conf: updated to reflect in-memory-only set_resolution semantics - conftest: settings_with_resolution fixture for widget tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e15ac99 commit f981dae

48 files changed

Lines changed: 587 additions & 344 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/trcc/adapters/device/scsi.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class ScsiDevice(FrameDevice):
7777
# Track which devices have been initialized (poll + init sent)
7878
_initialized_devices: Set[str] = set()
7979

80-
def __init__(self, device_path: str, width: int = 320, height: int = 320):
80+
def __init__(self, device_path: str, width: int = 0, height: int = 0):
8181
self.device_path = device_path
8282
self.width = width
8383
self.height = height
@@ -218,7 +218,7 @@ def _init_device(dev: str) -> tuple[int, bytes]:
218218
return fbl, response[:64]
219219

220220
@staticmethod
221-
def _send_frame(dev: str, rgb565_data: bytes, width: int = 320, height: int = 320):
221+
def _send_frame(dev: str, rgb565_data: bytes, width: int, height: int):
222222
"""Send one RGB565 frame in SCSI chunks sized for the resolution."""
223223
chunks = ScsiDevice._get_frame_chunks(width, height)
224224
total_size = sum(size for _, size in chunks)

src/trcc/adapters/infra/dc_config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ def __init__(self, filepath: str | Path | None = None):
6262
self.overlay_enabled: bool = True
6363
self.overlay_x: int = 0
6464
self.overlay_y: int = 0
65-
self.overlay_w: int = 320
66-
self.overlay_h: int = 320
65+
self.overlay_w: int = 0
66+
self.overlay_h: int = 0
6767

6868
# Mask
6969
self.mask_enabled: bool = False
@@ -151,15 +151,15 @@ def _to_theme_config(self):
151151

152152
# ── Overlay conversion ──
153153

154-
def to_overlay_config(self, width: int = 320, height: int = 320) -> dict:
154+
def to_overlay_config(self) -> dict:
155155
"""Convert to overlay renderer config dict."""
156156
parsed = {
157157
'elements': self.legacy_elements,
158158
'display_elements': self.elements,
159159
'custom_text': self.custom_text,
160160
'flags': self.flags,
161161
}
162-
return DcParser.to_overlay_config(parsed, width, height)
162+
return DcParser.to_overlay_config(parsed)
163163

164164
@classmethod
165165
def from_overlay_config(cls, overlay_config: dict,

src/trcc/adapters/infra/dc_parser.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ def _parse_display_elements(data: bytes, start_pos: int) -> List[DisplayElement]
445445
return elements
446446

447447
@staticmethod
448-
def to_overlay_config(dc_config: dict, display_width: int = 320, display_height: int = 320) -> dict:
448+
def to_overlay_config(dc_config: dict) -> dict:
449449
"""Convert parsed .dc config to overlay renderer config format."""
450450
elements = dc_config.get('elements', {})
451451
display_elements = dc_config.get('display_elements', [])
@@ -629,7 +629,7 @@ def list_configs(base_path: str) -> list:
629629
return sorted(str(dc_file) for dc_file in base.rglob('config1.dc'))
630630

631631
@staticmethod
632-
def validate_theme(theme_path: str, display_width: int = 320, display_height: int = 320) -> dict:
632+
def validate_theme(theme_path: str, display_width: int | None = None, display_height: int | None = None) -> dict:
633633
"""Validate a theme's config and return any issues found."""
634634
import os
635635

@@ -668,17 +668,18 @@ def validate_theme(theme_path: str, display_width: int = 320, display_height: in
668668
result['issues'].append('Time in config but not in display_elements (0xDD bug)')
669669
result['valid'] = False
670670

671-
for key, cfg in overlay.items():
672-
x, y = cfg.get('x', 0), cfg.get('y', 0)
673-
if x < 0 or x > display_width or y < 0 or y > display_height:
674-
result['warnings'].append(
675-
f'{key}: position ({x}, {y}) outside {display_width}x{display_height}')
676-
677-
mask = parsed.get('mask_settings', {})
678-
if mask.get('mask_enabled'):
679-
pos = mask.get('mask_position', (0, 0))
680-
if pos[0] < 0 or pos[0] > display_width or pos[1] < 0 or pos[1] > display_height:
681-
result['warnings'].append(f'Mask position {pos} may be outside bounds')
671+
if display_width is not None and display_height is not None:
672+
for key, cfg in overlay.items():
673+
x, y = cfg.get('x', 0), cfg.get('y', 0)
674+
if x < 0 or x > display_width or y < 0 or y > display_height:
675+
result['warnings'].append(
676+
f'{key}: position ({x}, {y}) outside {display_width}x{display_height}')
677+
678+
mask = parsed.get('mask_settings', {})
679+
if mask.get('mask_enabled'):
680+
pos = mask.get('mask_position', (0, 0))
681+
if pos[0] < 0 or pos[0] > display_width or pos[1] < 0 or pos[1] > display_height:
682+
result['warnings'].append(f'Mask position {pos} may be outside bounds')
682683

683684
mask_file = os.path.join(theme_path, '01.png')
684685
bg_file = os.path.join(theme_path, '00.png')

src/trcc/adapters/infra/media_player.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def _check_ffmpeg() -> bool:
4040
class VideoDecoder:
4141
"""Decode video frames via FFmpeg pipe. No playback state."""
4242

43-
def __init__(self, video_path: str, target_size: tuple[int, int] = (320, 320),
43+
def __init__(self, video_path: str, target_size: tuple[int, int],
4444
fit_mode: str = 'fill') -> None:
4545
if not FFMPEG_AVAILABLE:
4646
from trcc.core.builder import ControllerBuilder
@@ -155,7 +155,7 @@ def close(self) -> None:
155155
def extract_frames(
156156
video_path: str,
157157
output_dir: str,
158-
target_size: tuple[int, int] = (320, 320),
158+
target_size: tuple[int, int],
159159
max_frames: int | None = None,
160160
) -> int:
161161
"""Extract video frames to PNG files via FFmpeg."""
@@ -205,7 +205,7 @@ class ThemeZtDecoder:
205205
- for each frame: int32 size + JPEG bytes
206206
"""
207207

208-
def __init__(self, zt_path: str, target_size: tuple[int, int] | None = None) -> None:
208+
def __init__(self, zt_path: str, target_size: tuple[int, int]) -> None:
209209
self.frames: list[RawFrame] = []
210210
self.timestamps: list[int] = []
211211
self.delays: list[int] = []
@@ -236,12 +236,10 @@ def __init__(self, zt_path: str, target_size: tuple[int, int] | None = None) ->
236236

237237
@staticmethod
238238
def _decode_jpeg(jpeg_bytes: bytes,
239-
target_size: tuple[int, int] | None) -> RawFrame:
239+
target_size: tuple[int, int]) -> RawFrame:
240240
"""Decode JPEG bytes → RawFrame via ffmpeg pipe."""
241-
vf_args: list[str] = []
242-
if target_size:
243-
tw, th = target_size
244-
vf_args = ['-vf', f'scale={tw}:{th}']
241+
tw, th = target_size
242+
vf_args = ['-vf', f'scale={tw}:{th}']
245243
cmd = [
246244
'ffmpeg', '-f', 'jpeg_pipe', '-i', 'pipe:0',
247245
*vf_args,
@@ -256,10 +254,10 @@ def _decode_jpeg(jpeg_bytes: bytes,
256254
raise ValueError(f"ffmpeg decode failed: {result.stderr[:100]!r}")
257255
except Exception as exc:
258256
log.warning("ThemeZtDecoder: JPEG decode failed (%s), returning blank", exc)
259-
w, h = target_size if target_size else (320, 320)
257+
w, h = target_size
260258
return RawFrame(bytes(w * h * 3), w, h)
261259

262-
w, h = target_size if target_size else (320, 320)
260+
w, h = target_size
263261
return RawFrame(result.stdout[:w * h * 3], w, h)
264262

265263
@property

src/trcc/adapters/infra/theme_cloud.py

Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
from urllib.error import HTTPError, URLError
4141
from urllib.request import Request, urlopen
4242

43+
from trcc.core.models import CLOUD_SERVERS, CLOUD_THEME_URL_KEYS
44+
4345
log = logging.getLogger(__name__)
4446

4547
# Category definitions matching Windows FormCZTV.CheakWebFile
@@ -58,46 +60,9 @@
5860
# Category name lookup
5961
CATEGORY_NAMES = {cat[0]: cat[1] for cat in CATEGORIES}
6062

61-
# Server URLs (Windows pattern: http://www.czhorde.com/tr/bj{resolution}/)
62-
SERVERS = {
63-
'international': 'http://www.czhorde.cc/tr/bj{resolution}/',
64-
'china': 'http://www.czhorde.com/tr/bj{resolution}/',
65-
}
66-
67-
# Resolution URL suffixes — full C# v2.1.2 GifWebDir* parity
68-
RESOLUTION_URLS = {
69-
"240x240": "bj240240",
70-
"240x320": "bj240320",
71-
"320x240": "bj320240",
72-
"320x320": "bj320320",
73-
"360x360": "bj360360",
74-
"480x480": "bj480480",
75-
"640x172": "bj640172",
76-
"640x480": "bj640480",
77-
"800x480": "bj800480",
78-
"854x480": "bj854480",
79-
"960x320": "bj960320",
80-
"960x540": "bj960540",
81-
"1280x480": "bj1280480",
82-
"1600x720": "bj1600720",
83-
"1600x720u": "bj1600720u",
84-
"1600x720l": "bj1600720l",
85-
"1920x440": "bj1920440",
86-
"1920x462": "bj1920462",
87-
# Portrait variants
88-
"172x640": "bj172640",
89-
"320x960": "bj320960",
90-
"440x1920": "bj4401920",
91-
"462x1920": "bj4621920",
92-
"480x640": "bj480640",
93-
"480x800": "bj480800",
94-
"480x854": "bj480854",
95-
"480x1280": "bj4801280",
96-
"540x960": "bj540960",
97-
"720x1600": "bj7201600",
98-
"720x1600u": "bj7201600u",
99-
"720x1600l": "bj7201600l",
100-
}
63+
# Re-export for backward compatibility
64+
SERVERS = CLOUD_SERVERS
65+
RESOLUTION_URLS = CLOUD_THEME_URL_KEYS
10166

10267

10368
class CloudThemeDownloader:
@@ -138,7 +103,7 @@ def get_themes_by_category(category: str) -> List[str]:
138103

139104
def __init__(
140105
self,
141-
resolution: str = "320x320",
106+
resolution: str = '',
142107
cache_dir: Optional[str] = None,
143108
server: str = 'international'
144109
):
@@ -466,7 +431,7 @@ def get_cached_themes(self) -> List[str]:
466431

467432
def download_theme(
468433
theme_id: str,
469-
resolution: str = "320x320",
434+
resolution: str,
470435
cache_dir: Optional[str] = None,
471436
) -> Optional[str]:
472437
"""Quick download of a single theme (convenience wrapper)."""

src/trcc/adapters/system/linux/setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
_posix_raise_existing_instance,
2121
_print_summary,
2222
)
23+
from trcc.core.paths import _TRCC_PKG
2324
from trcc.core.platform import is_root
2425
from trcc.core.ports import PlatformSetup
2526

@@ -250,8 +251,7 @@ def setup_selinux() -> int:
250251
print(f" {tool} not found — {_install_hint(tool, pm)}")
251252
return 1
252253

253-
trcc_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
254-
te_src = os.path.join(trcc_root, 'data', 'trcc_usb.te')
254+
te_src = os.path.join(_TRCC_PKG, 'data', 'trcc_usb.te')
255255
if not os.path.isfile(te_src):
256256
print(f"SELinux policy source not found: {te_src}")
257257
return 1
@@ -301,7 +301,7 @@ def install_desktop() -> int:
301301
home = _real_user_home()
302302
app_dir = home / ".local" / "share" / "applications"
303303

304-
pkg_root = Path(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
304+
pkg_root = Path(_TRCC_PKG)
305305
icon_pkg_dir = pkg_root / "assets" / "icons"
306306
desktop_src = pkg_root / "assets" / "trcc-linux.desktop"
307307

src/trcc/api/themes.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def _preview_url(theme_name: str, theme_dir: str) -> str:
4646

4747

4848
@router.post("/init")
49-
def init_theme_data(resolution: str = "320x320") -> dict:
49+
def init_theme_data(resolution: str) -> dict:
5050
"""Download and extract theme/web/mask archives for a resolution.
5151
5252
Safe to call repeatedly — no-op if data is already cached.
@@ -67,7 +67,7 @@ def init_theme_data(resolution: str = "320x320") -> dict:
6767

6868

6969
@router.get("")
70-
def list_themes(resolution: str = "320x320") -> list[ThemeResponse]:
70+
def list_themes(resolution: str) -> list[ThemeResponse]:
7171
"""List available local themes for a given resolution."""
7272
w, h = _parse_resolution(resolution)
7373

@@ -90,7 +90,7 @@ def list_themes(resolution: str = "320x320") -> list[ThemeResponse]:
9090

9191

9292
@router.get("/web")
93-
def list_web_themes(resolution: str = "320x320") -> list[WebThemeResponse]:
93+
def list_web_themes(resolution: str) -> list[WebThemeResponse]:
9494
"""List available cloud theme previews for a given resolution."""
9595
w, h = _parse_resolution(resolution)
9696

@@ -137,12 +137,16 @@ def download_web_theme(
137137
from trcc.adapters.infra.theme_cloud import CloudThemeDownloader
138138
from trcc.api import _display_dispatcher
139139

140-
# Resolve resolution from device or parameter
141-
w, h = 320, 320
140+
# Resolve resolution from parameter or connected device
142141
if resolution:
143142
w, h = _parse_resolution(resolution)
144143
elif _display_dispatcher and _display_dispatcher.connected:
145144
w, h = _display_dispatcher.resolution # type: ignore[union-attr]
145+
else:
146+
raise HTTPException(
147+
status_code=400,
148+
detail="resolution required — no device connected and no resolution specified",
149+
)
146150

147151
if send and (not _display_dispatcher or not _display_dispatcher.connected):
148152
raise HTTPException(
@@ -180,7 +184,7 @@ def download_web_theme(
180184

181185

182186
@router.get("/masks")
183-
def list_masks(resolution: str = "320x320") -> list[MaskResponse]:
187+
def list_masks(resolution: str) -> list[MaskResponse]:
184188
"""List available mask overlays for a given resolution."""
185189
w, h = _parse_resolution(resolution)
186190

@@ -252,7 +256,10 @@ def load_theme(body: ThemeLoadRequest) -> dict:
252256
if res:
253257
w, h = res
254258
else:
255-
w, h = getattr(api._display_dispatcher, 'lcd_size', (320, 320))
259+
raise HTTPException(
260+
status_code=409,
261+
detail="No device connected and no resolution available",
262+
)
256263

257264
if is_animated and theme_path:
258265
# Find video file (Theme.zt or .mp4)
@@ -290,19 +297,30 @@ def save_theme(body: ThemeSaveRequest) -> dict:
290297

291298

292299
@router.post("/export")
293-
def export_theme(theme_name: str, resolution: str = "320x320") -> Response:
300+
def export_theme(theme_name: str, resolution: str | None = None) -> Response:
294301
"""Export a theme as a downloadable .tr archive."""
295302
import re
296303
import tempfile
297304
from pathlib import Path
298305

299306
from fastapi.responses import FileResponse
300307

308+
from trcc.api import _display_dispatcher
309+
301310
# Validate theme_name — no path traversal
302311
if not re.fullmatch(r'[a-zA-Z0-9_ \-().]+', theme_name):
303312
raise HTTPException(status_code=400, detail="Invalid theme name")
304313

305-
w, h = _parse_resolution(resolution)
314+
# Resolve resolution from query param or connected device
315+
if resolution:
316+
w, h = _parse_resolution(resolution)
317+
elif _display_dispatcher and _display_dispatcher.connected:
318+
w, h = _display_dispatcher.resolution # type: ignore[union-attr]
319+
else:
320+
raise HTTPException(
321+
status_code=400,
322+
detail="resolution required — no device connected and no resolution specified",
323+
)
306324

307325
from trcc.adapters.infra.data_repository import ThemeDir
308326
td = ThemeDir.for_resolution(w, h)
@@ -362,9 +380,12 @@ async def import_theme(file: UploadFile) -> dict:
362380
try:
363381
from trcc.adapters.infra.data_repository import ThemeDir
364382
from trcc.api import _display_dispatcher
365-
w, h = (320, 320)
366-
if _display_dispatcher and _display_dispatcher.connected:
367-
w, h = _display_dispatcher.resolution # type: ignore[union-attr]
383+
if not _display_dispatcher or not _display_dispatcher.connected:
384+
raise HTTPException(
385+
status_code=409,
386+
detail="No device connected. POST /devices/{id}/select first.",
387+
)
388+
w, h = _display_dispatcher.resolution # type: ignore[union-attr]
368389
data_dir = Path(str(ThemeDir.for_resolution(w, h)))
369390
from trcc.adapters.infra.dc_config import DcConfig
370391
from trcc.adapters.infra.dc_parser import load_config_json

src/trcc/cli/_display.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,11 @@ def resume(builder):
479479
try:
480480
svc.select(dev)
481481
lcd = builder.lcd_from_service(svc)
482+
# Apply hardware resolution so the display pipeline encodes correctly.
483+
# dev.resolution is set by discover_resolution() (USB handshake result).
484+
w, h = dev.resolution
485+
if w and h:
486+
lcd.set_resolution(w, h)
482487
lcd.restore_device_settings()
483488
result = lcd.load_last_theme()
484489
if not result.get("success"):

0 commit comments

Comments
 (0)