Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ Run `lsusb` to find your USB ID (`xxxx:xxxx` after `ID`), then match it below.
**Bulk USB devices** — raw USB protocol:
| USB ID | Devices |
|--------|---------|
| `87AD:70DB` | GrandVision 360 AIO, Mjolnir Vision 360, Wonder Vision Pro 360, Frozen Warframe Pro |
| `87AD:70DB` | GrandVision 360 AIO, Mjolnir Vision 360, Wonder Vision Pro 360, Frozen Warframe Pro, Phantom Spirit 120 Vision EVO |

**LY USB devices** — chunked bulk protocol:
| USB ID | Devices |
Expand Down
3 changes: 2 additions & 1 deletion doc/REFERENCE_DEVICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ These have a full pixel LCD (240x240 to 1920x462) for custom themes, images, vid
| Peerless Assassin 120 Vision | — |
| Royal Lord 120 Vision | — |
| Royal Knight 130 Vision | — |
| Phantom Spirit 120 Vision | — |
| Phantom Spirit 120 Vision / EVO | 幻灵 120 Vision |
| Phantom Spirit 120 Vision EVO | Bulk (87AD:70DB, PM 4 SUB 4 A1LF10V) | [alan7383](https://github.com/alan7383) |

### LED + Segment Display Products (Digital Series)

Expand Down
2 changes: 1 addition & 1 deletion doc/TESTERS_WANTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ These devices have been tested on real Linux hardware by contributors:
| GrandVision 360 AIO | `87AD:70DB` | Bulk | [bipobuilt](https://github.com/bipobuilt) |
| Mjolnir Vision 360 | `87AD:70DB` | Bulk | [Pikarz](https://github.com/Pikarz) |
| Trofeo Vision LCD | `0416:5302` | HID Type 2 | [PantherX12max](https://github.com/PantherX12max), [N8ghtz](https://github.com/N8ghtz) |
| Phantom Spirit 120 Vision EVO | `87AD:70DB` | Bulk | [alan7383](https://github.com/alan7383) |
| Assassin Spirit 120 Vision ARGB | `0416:5302` | HID Type 2 | [michael-spinelli](https://github.com/michael-spinelli) |
| Frozen Warframe SE | `0402:3922` | SCSI | [apj202-ops](https://github.com/apj202-ops) |
| FROZEN WARFRAME (SCSI) | `0402:3922` | SCSI | [gizbo](https://github.com/gizbo) |
Expand Down Expand Up @@ -138,7 +139,6 @@ The following models are listed on [Thermalright's official download page](https
| MC-3 DIGITAL | HID LCD or LED | Need tester |
| Peerless Assassin 140 Digital | HID LCD or LED | Need tester |
| Peerless Vision | HID LCD or LED | Need tester |
| Phantom Spirit 120 Digital/Vision | HID LCD or LED | Need tester |
| Rainbow Vision | HID LCD or LED | Need tester |
| Royal Knight 130 Digital/Vision | HID LCD or LED | Need tester |
| Royal Lord 120 Vision | HID LCD or LED | Need tester |
Expand Down
6 changes: 5 additions & 1 deletion src/trcc/adapters/diagnostics/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,17 +309,21 @@ def check_udev_rules_linux() -> HealthCheckResult:
message="Not applicable on this OS",
)
candidate_paths = [
Path("/etc/udev/rules.d/99-trcc-lcd.rules"),
Path("/etc/udev/rules.d/99-trcc.rules"),
Path("/etc/udev/rules.d/90-trcc.rules"),
Path("/lib/udev/rules.d/99-trcc-lcd.rules"),
Path("/lib/udev/rules.d/99-trcc.rules"),
Path("/usr/lib/udev/rules.d/99-trcc-lcd.rules"),
Path("/usr/lib/udev/rules.d/99-trcc.rules"),
]
found = [p for p in candidate_paths if p.is_file()]
if not found:
return HealthCheckResult(
name="udev-rules", severity="WARN",
message="No TRCC udev rules found under /etc/udev/rules.d/",
fix_hint="Run `trcc system setup` (or install via the distro "
"package) to lay down /etc/udev/rules.d/99-trcc.rules",
"package) to lay down /etc/udev/rules.d/99-trcc-lcd.rules",
)
return HealthCheckResult(
name="udev-rules", severity="OK",
Expand Down
4 changes: 3 additions & 1 deletion src/trcc/adapters/render/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,9 @@ def get_pixels_rgb(

# ── Legacy boundary (raw RGB24 video frame → QImage) ──────────────

def from_raw_rgb24(self, frame: RawFrame) -> Any:
def from_raw_rgb24(self, frame: Any) -> Any:
if isinstance(frame, QImage):
return frame.convertToFormat(QImage.Format.Format_ARGB32)
log.debug("from_raw_rgb24: %dx%d", frame.width, frame.height)
qimg = QImage(
frame.data, frame.width, frame.height,
Expand Down
2 changes: 1 addition & 1 deletion src/trcc/adapters/system/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ def no_devices_hint(self) -> str:
log.debug("LinuxPlatform.no_devices_hint: called")
return (
"Run `trcc system setup` to install the udev rules "
"(/etc/udev/rules.d/99-trcc.rules), then replug the device."
"(/etc/udev/rules.d/99-trcc-lcd.rules), then replug the device."
)

# ── Hardware probes (LED memory + disk widgets) ───────────────────
Expand Down
7 changes: 7 additions & 0 deletions src/trcc/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,13 @@ def _on_visual_change(self, event: Any) -> None:
type(event).__name__, key,
)
continue
ds = self._app.settings.for_device(key)
if ds.screencast_region is not None:
log.debug(
"DeviceRenderObserver: skip %s for %s (screencast active)",
type(event).__name__, key,
)
continue
log.debug(
"DeviceRenderObserver: %s for %s → RenderAndSend",
type(event).__name__, key,
Expand Down
6 changes: 3 additions & 3 deletions src/trcc/core/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ def oriented_theme_path(
return cand if cand.exists() else stored


_VIDEO_EXTS_FOR_SAVE = frozenset({".mp4", ".mov", ".webm", ".zt", ".mkv", ".avi"})
_VIDEO_EXTS_FOR_SAVE = frozenset({".mp4", ".mov", ".webm", ".zt", ".mkv", ".avi", ".gif"})


_VIDEO_EXTS_OK = frozenset({".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt"})
_VIDEO_EXTS_OK = frozenset({".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt", ".gif"})


_BG_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".bmp", ".webp"})
Expand All @@ -92,7 +92,7 @@ def oriented_theme_path(


_VIDEO_EXTS_FOR_LOAD = frozenset({
".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt",
".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt", ".gif",
})


Expand Down
21 changes: 12 additions & 9 deletions src/trcc/core/commands/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,13 @@ def execute(self, app: App) -> RenderResult:
except DeviceNotFoundError as e:
return RenderResult(ok=False, key=self.key, message=str(e))

ds_dev = app.settings.for_device(self.key)
if ds_dev.screencast_region is not None:
return RenderResult(
ok=True, key=self.key,
message="screencast active — theme render skipped",
)

theme = app.active_themes.get(self.key)
if theme is None:
return RenderResult(
Expand Down Expand Up @@ -1049,28 +1056,24 @@ def execute(self, app: App) -> VideoResult:

@dataclass(frozen=True, slots=True)
class StopVideo(Command[VideoResult]):
"""Clear the device's playback override AND the persisted bg override.
"""Clear the device's playback override AND optionally the persisted bg override.

Idempotent — calling on a device with no playback is a no-op + ok=True
so scripts can use it as a defensive cleanup.

Clears ``DeviceSettings.background_path`` so the next render falls
back to the active theme's bundled background. Without this, the
next ``RenderAndSend`` (triggered by ``VideoStopped`` via
``DeviceRenderObserver``) would find ``background_path`` still set,
take the override branch in ``DisplayService._resolve_background``,
and silently re-decode the same video via ``MediaService.load_video``
— turning "stop" into "rewind to frame 0".
Clears ``DeviceSettings.background_path`` (unless ``keep_override=True``)
so the next render falls back to the active theme's bundled background.
"""
key: str
keep_override: bool = False

def execute(self, app: App) -> VideoResult:
had_playback = app.media.playback(self.key) is not None
had_override = (
app.settings.for_device(self.key).background_path is not None
)
app.media.unload(self.key)
if had_override:
if had_override and not self.keep_override:
log.info(
"StopVideo: clearing background_path override for %s",
self.key,
Expand Down
36 changes: 24 additions & 12 deletions src/trcc/core/commands/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -1556,21 +1556,33 @@ def execute(self, app: App) -> ThemeResult:
# the same name, so restore must NOT re-resolve a shipped pointer to
# the user one — it loads whatever the user last selected.
candidate = oriented_theme_path(app, self.key, candidate)
return LoadTheme(
result = LoadTheme(
key=self.key, path=candidate, reset_overrides=False,
).execute(app)
else:
# Legacy bare-name value — search the known theme roots.
resolved = _search_theme_by_name(app, self.key, stored)
if resolved is None:
return ThemeResult(
ok=False, key=self.key, theme_name=stored,
message=(f"Persisted theme {stored!r} not found in any "
"known theme root for this device"),
)
result = LoadTheme(
key=self.key, path=resolved, reset_overrides=False,
).execute(app)

# Legacy bare-name value — search the known theme roots.
resolved = _search_theme_by_name(app, self.key, stored)
if resolved is None:
return ThemeResult(
ok=False, key=self.key, theme_name=stored,
message=(f"Persisted theme {stored!r} not found in any "
"known theme root for this device"),
)
return LoadTheme(
key=self.key, path=resolved, reset_overrides=False,
).execute(app)
if result.ok:
bg = app.settings.for_device(self.key).background_path
if bg and Path(bg).exists():
ext = Path(bg).suffix.lower()
if ext in _VIDEO_EXTS_FOR_LOAD:
log.info(
"RestoreLastTheme: %s replaying persisted background video %s",
self.key, bg,
)
PlayVideo(key=self.key, path=Path(bg)).execute(app)
return result

@dataclass(frozen=True, slots=True)
class RestoreDeviceState(Command[ThemeResult]):
Expand Down
2 changes: 1 addition & 1 deletion src/trcc/services/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
log = logging.getLogger(__name__)


_VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt"}
_VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt", ".gif"}
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}


Expand Down
2 changes: 1 addition & 1 deletion src/trcc/services/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class DiscoveredMask:
# JPEG-sequence archive UCVideoCut writes). No ``background.*`` —
# that name never existed in legacy or Windows TRCC.
_VIDEO_CANDIDATES = (
"Theme.mp4", "Theme.mov", "Theme.webm", "Theme.zt",
"Theme.mp4", "Theme.mov", "Theme.webm", "Theme.zt", "Theme.gif",
)
# Video container extensions we ship (derived from _VIDEO_CANDIDATES so the
# two never drift); the background allowlist is those plus the static PNG.
Expand Down
8 changes: 8 additions & 0 deletions src/trcc/ui/gui/display_mode_panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,10 @@ def set_enabled(self, enabled):
self.toggle_btn.blockSignals(False)
self._set_actions_enabled(enabled)

@property
def is_enabled(self) -> bool:
return self.toggle_btn.isChecked()

def set_title(self, text: str) -> None:
"""Update the title label text."""
self._title_lbl.setText(text)
Expand Down Expand Up @@ -681,6 +685,10 @@ def set_values(self, x=None, y=None, w=None, h=None):
def set_resolution(self, width, height):
"""Set LCD resolution for aspect ratio calculations."""
self._resolution = (width, height)
if not self.entry_w.text() or self.entry_w.text() == "0":
self.entry_w.setText(str(width))
if not self.entry_h.text() or self.entry_h.text() == "0":
self.entry_h.setText(str(height))

def set_aspect_lock(self, enabled):
self._aspect_lock = enabled
Expand Down
32 changes: 18 additions & 14 deletions src/trcc/ui/gui/lcd_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
SetOverlayConfig,
SetSplitMode,
StopVideo,
UploadCustomMask,
VideoStatus,
)
from ..presentation.lcd_presentation_model import LcdPresentationModel
Expand Down Expand Up @@ -563,17 +562,13 @@ def apply_mask(self, mask_info: Any) -> None:
self._w['preview'].set_status(f"Mask: {mask_info.name}")
return
mask_dir = Path(mask_info.path)
# DC first — sets overlay resolution + element positions for this mask
self._load_theme_overlay_config(mask_dir, persist=False)
is_custom = getattr(mask_info, 'is_custom', False)
if is_custom:
r = self._app.dispatch(UploadCustomMask(
key=self._device_key, source=mask_dir,
))
else:
r = self._app.dispatch(ApplyMask(
key=self._device_key, path=mask_dir,
))
# DC first — sets overlay resolution + element positions for this mask (if present)
overlay_config = dc_as_legacy_overlay_config(mask_dir)
if overlay_config:
self._load_theme_overlay_config(mask_dir, persist=False)
r = self._app.dispatch(ApplyMask(
key=self._device_key, path=mask_dir,
))
if r.ok:
self._w['preview'].set_status(r.message)
else:
Expand Down Expand Up @@ -983,6 +978,9 @@ def rebuild_preview(self) -> None:
self._device_key,
)
return
theme_setting = self._w.get('theme_setting')
if theme_setting and getattr(theme_setting, 'screencast_panel', None) and theme_setting.screencast_panel.is_enabled:
return
image = self._app.display.rendered_surface(self._device_key)
if image is None:
# No frame rendered yet (pre-load) — build a one-off surface.
Expand Down Expand Up @@ -1179,7 +1177,7 @@ def on_screencast_frame(self, image: Any) -> None:
return
try:
data = self._app.display.build_screencast_frame(
info=device.info, frame=image,
info=device.info, frame=image, profile=device.profile,
)
except Exception as e:
self.log.warning(
Expand Down Expand Up @@ -1307,6 +1305,12 @@ def _render_and_send(self) -> None:
"_render_and_send: skipped — animation timer owns the wire",
)
return
theme_setting = self._w.get('theme_setting')
if theme_setting and getattr(theme_setting, 'screencast_panel', None) and theme_setting.screencast_panel.is_enabled:
self.log.debug(
"_render_and_send: skipped — screencast owns the wire",
)
return
device = self._app.devices.get(self._device_key)
if device is None or not device.is_connected:
self.log.debug(
Expand Down Expand Up @@ -1481,7 +1485,7 @@ def set_inactive(self) -> None:
def _cleanup_device(self) -> None:
"""Release LCD resources via Commands."""
from ...core.commands import SendColor
self._app.dispatch(StopVideo(key=self._device_key))
self._app.dispatch(StopVideo(key=self._device_key, keep_override=True))
try:
# Best-effort black-frame so the screen visibly goes blank.
self._app.dispatch(SendColor(
Expand Down
26 changes: 21 additions & 5 deletions src/trcc/ui/gui/trcc_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ def _on_bus_frame_sent(self, event: Any) -> None:
handler.handle_frame(surface)
elif colors:
handler.handle_frame({"display_colors": list(colors)})
else:
elif not self._screencast.active:
handler.rebuild_preview()

def _on_bus_video_started(self, event: Any) -> None:
Expand Down Expand Up @@ -1823,6 +1823,11 @@ def _on_screencast_toggle(self, enabled: bool) -> None:
# render hint, not a session-lifecycle fact.
self._screencast.set_lcd_size(w, hw)
x, y, sw, sh = self._screencast.params
if sw <= 0 or sh <= 0:
sw, sh = w, hw
self._screencast.set_params(x, y, sw, sh)
if hasattr(self, 'uc_theme_setting'):
self.uc_theme_setting.screencast_panel.set_coords(x=x, y=y, w=sw, h=sh)
result = self._app.dispatch(StartScreencast(
key=h.device_key, x=x, y=y, w=sw, h=sh,
audio=self._screencast.audio_enabled,
Expand Down Expand Up @@ -2153,15 +2158,26 @@ def _on_video_cut_done(self, zt_path: Any) -> None:
self._hide_cutters()
h = self._active_lcd()
if zt_path and h:
import shutil
target_dir = (
self._app.platform.paths().user_content_dir() / "backgrounds"
)
target_dir.mkdir(parents=True, exist_ok=True)
safe_key = h.device_key.replace(":", "_") or "default"
target = target_dir / f"{safe_key}.zt"
try:
shutil.copy2(zt_path, target)
final_path = target
except Exception as e:
log.warning("_on_video_cut_done: failed to copy %s to %s: %s", zt_path, target, e)
final_path = Path(zt_path)

# ``SetBackground`` persists the .zt as the device's
# background override (``DeviceSettings.background_path``)
# THEN delegates to ``PlayVideo`` for the decode/animate
# pipeline — same as the image cutter's ``_on_image_cut_done``.
# Dispatching ``PlayVideo`` directly here (the old code) left
# no override for ``SaveTheme`` to bake in, so a saved theme
# lost the video and reloaded with a black background.
result = self._app.dispatch(SetBackground(
key=h.device_key, path=Path(zt_path),
key=h.device_key, path=final_path,
))
if result.ok:
self.uc_preview.set_playing(True)
Expand Down
Loading