Add LED skip start/end, rename standby_interval to keepalive_interval, remove migrations
LED skip: set first N and last M LEDs to black on a target. Color sources (static, gradient, effect, color cycle) render across only the active (non-skipped) LEDs. Processor pads with blacks before sending to device. Rename standby_interval → keepalive_interval across all Python, API schemas, and JS. from_dict falls back to old key for existing configs. Remove legacy migration functions (_migrate_devices_to_targets, _migrate_targets_to_color_strips) and legacy fields from target model. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -95,8 +95,10 @@ def _target_to_response(target) -> PictureTargetResponse:
|
|||||||
device_id=target.device_id,
|
device_id=target.device_id,
|
||||||
color_strip_source_id=target.color_strip_source_id,
|
color_strip_source_id=target.color_strip_source_id,
|
||||||
fps=target.fps,
|
fps=target.fps,
|
||||||
standby_interval=target.standby_interval,
|
keepalive_interval=target.keepalive_interval,
|
||||||
state_check_interval=target.state_check_interval,
|
state_check_interval=target.state_check_interval,
|
||||||
|
led_skip_start=target.led_skip_start,
|
||||||
|
led_skip_end=target.led_skip_end,
|
||||||
description=target.description,
|
description=target.description,
|
||||||
created_at=target.created_at,
|
created_at=target.created_at,
|
||||||
updated_at=target.updated_at,
|
updated_at=target.updated_at,
|
||||||
@@ -150,8 +152,10 @@ async def create_target(
|
|||||||
device_id=data.device_id,
|
device_id=data.device_id,
|
||||||
color_strip_source_id=data.color_strip_source_id,
|
color_strip_source_id=data.color_strip_source_id,
|
||||||
fps=data.fps,
|
fps=data.fps,
|
||||||
standby_interval=data.standby_interval,
|
keepalive_interval=data.keepalive_interval,
|
||||||
state_check_interval=data.state_check_interval,
|
state_check_interval=data.state_check_interval,
|
||||||
|
led_skip_start=data.led_skip_start,
|
||||||
|
led_skip_end=data.led_skip_end,
|
||||||
picture_source_id=data.picture_source_id,
|
picture_source_id=data.picture_source_id,
|
||||||
key_colors_settings=kc_settings,
|
key_colors_settings=kc_settings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -264,8 +268,10 @@ async def update_target(
|
|||||||
device_id=data.device_id,
|
device_id=data.device_id,
|
||||||
color_strip_source_id=data.color_strip_source_id,
|
color_strip_source_id=data.color_strip_source_id,
|
||||||
fps=data.fps,
|
fps=data.fps,
|
||||||
standby_interval=data.standby_interval,
|
keepalive_interval=data.keepalive_interval,
|
||||||
state_check_interval=data.state_check_interval,
|
state_check_interval=data.state_check_interval,
|
||||||
|
led_skip_start=data.led_skip_start,
|
||||||
|
led_skip_end=data.led_skip_end,
|
||||||
picture_source_id=data.picture_source_id,
|
picture_source_id=data.picture_source_id,
|
||||||
key_colors_settings=kc_settings,
|
key_colors_settings=kc_settings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -276,8 +282,10 @@ async def update_target(
|
|||||||
target.sync_with_manager(
|
target.sync_with_manager(
|
||||||
manager,
|
manager,
|
||||||
settings_changed=(data.fps is not None or
|
settings_changed=(data.fps is not None or
|
||||||
data.standby_interval is not None or
|
data.keepalive_interval is not None or
|
||||||
data.state_check_interval is not None or
|
data.state_check_interval is not None or
|
||||||
|
data.led_skip_start is not None or
|
||||||
|
data.led_skip_end is not None or
|
||||||
data.key_colors_settings is not None),
|
data.key_colors_settings is not None),
|
||||||
source_changed=data.color_strip_source_id is not None,
|
source_changed=data.color_strip_source_id is not None,
|
||||||
device_changed=data.device_id is not None,
|
device_changed=data.device_id is not None,
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ class PictureTargetCreate(BaseModel):
|
|||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
standby_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
|
keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
|
||||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
||||||
|
led_skip_start: int = Field(default=0, ge=0, description="Number of LEDs at the start to keep black")
|
||||||
|
led_skip_end: int = Field(default=0, ge=0, description="Number of LEDs at the end to keep black")
|
||||||
# KC target fields
|
# KC target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
|
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||||
@@ -70,8 +72,10 @@ class PictureTargetUpdate(BaseModel):
|
|||||||
device_id: Optional[str] = Field(None, description="LED device ID")
|
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||||
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
standby_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
|
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
|
||||||
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
||||||
|
led_skip_start: Optional[int] = Field(None, ge=0, description="Number of LEDs at the start to keep black")
|
||||||
|
led_skip_end: Optional[int] = Field(None, ge=0, description="Number of LEDs at the end to keep black")
|
||||||
# KC target fields
|
# KC target fields
|
||||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
|
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||||
@@ -88,8 +92,10 @@ class PictureTargetResponse(BaseModel):
|
|||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
fps: Optional[int] = Field(None, description="Target send FPS")
|
fps: Optional[int] = Field(None, description="Target send FPS")
|
||||||
standby_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
||||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
|
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
|
||||||
|
led_skip_start: int = Field(default=0, description="LEDs skipped at start")
|
||||||
|
led_skip_end: int = Field(default=0, description="LEDs skipped at end")
|
||||||
# KC target fields
|
# KC target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ class ProcessingSettings:
|
|||||||
brightness: float = 1.0
|
brightness: float = 1.0
|
||||||
smoothing: float = 0.3
|
smoothing: float = 0.3
|
||||||
interpolation_mode: str = "average"
|
interpolation_mode: str = "average"
|
||||||
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||||
|
|||||||
@@ -269,8 +269,10 @@ class ProcessorManager:
|
|||||||
device_id: str,
|
device_id: str,
|
||||||
color_strip_source_id: str = "",
|
color_strip_source_id: str = "",
|
||||||
fps: int = 30,
|
fps: int = 30,
|
||||||
standby_interval: float = 1.0,
|
keepalive_interval: float = 1.0,
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
|
led_skip_start: int = 0,
|
||||||
|
led_skip_end: int = 0,
|
||||||
):
|
):
|
||||||
"""Register a WLED target processor."""
|
"""Register a WLED target processor."""
|
||||||
if target_id in self._processors:
|
if target_id in self._processors:
|
||||||
@@ -283,8 +285,10 @@ class ProcessorManager:
|
|||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
color_strip_source_id=color_strip_source_id,
|
||||||
fps=fps,
|
fps=fps,
|
||||||
standby_interval=standby_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
|
led_skip_start=led_skip_start,
|
||||||
|
led_skip_end=led_skip_end,
|
||||||
ctx=self._build_context(),
|
ctx=self._build_context(),
|
||||||
)
|
)
|
||||||
self._processors[target_id] = proc
|
self._processors[target_id] = proc
|
||||||
|
|||||||
@@ -41,16 +41,20 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
device_id: str,
|
device_id: str,
|
||||||
color_strip_source_id: str,
|
color_strip_source_id: str,
|
||||||
fps: int,
|
fps: int,
|
||||||
standby_interval: float,
|
keepalive_interval: float,
|
||||||
state_check_interval: int,
|
state_check_interval: int,
|
||||||
ctx: TargetContext,
|
led_skip_start: int = 0,
|
||||||
|
led_skip_end: int = 0,
|
||||||
|
ctx: TargetContext = None,
|
||||||
):
|
):
|
||||||
super().__init__(target_id, ctx)
|
super().__init__(target_id, ctx)
|
||||||
self._device_id = device_id
|
self._device_id = device_id
|
||||||
self._color_strip_source_id = color_strip_source_id
|
self._color_strip_source_id = color_strip_source_id
|
||||||
self._target_fps = fps if fps > 0 else 30
|
self._target_fps = fps if fps > 0 else 30
|
||||||
self._standby_interval = standby_interval
|
self._keepalive_interval = keepalive_interval
|
||||||
self._state_check_interval = state_check_interval
|
self._state_check_interval = state_check_interval
|
||||||
|
self._led_skip_start = max(0, led_skip_start)
|
||||||
|
self._led_skip_end = max(0, led_skip_end)
|
||||||
|
|
||||||
# Runtime state (populated on start)
|
# Runtime state (populated on start)
|
||||||
self._led_client: Optional[LEDClient] = None
|
self._led_client: Optional[LEDClient] = None
|
||||||
@@ -126,7 +130,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
)
|
)
|
||||||
from wled_controller.core.processing.effect_stream import EffectColorStripStream
|
from wled_controller.core.processing.effect_stream import EffectColorStripStream
|
||||||
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream, EffectColorStripStream)) and device_info.led_count > 0:
|
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream, EffectColorStripStream)) and device_info.led_count > 0:
|
||||||
stream.configure(device_info.led_count)
|
effective_leds = device_info.led_count - self._led_skip_start - self._led_skip_end
|
||||||
|
stream.configure(max(1, effective_leds))
|
||||||
|
|
||||||
# Notify stream manager of our target FPS so it can adjust capture rate
|
# Notify stream manager of our target FPS so it can adjust capture rate
|
||||||
css_manager.notify_target_fps(
|
css_manager.notify_target_fps(
|
||||||
@@ -209,10 +214,14 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
css_manager.notify_target_fps(
|
css_manager.notify_target_fps(
|
||||||
self._color_strip_source_id, self._target_id, self._target_fps
|
self._color_strip_source_id, self._target_id, self._target_fps
|
||||||
)
|
)
|
||||||
if "standby_interval" in settings:
|
if "keepalive_interval" in settings:
|
||||||
self._standby_interval = settings["standby_interval"]
|
self._keepalive_interval = settings["keepalive_interval"]
|
||||||
if "state_check_interval" in settings:
|
if "state_check_interval" in settings:
|
||||||
self._state_check_interval = settings["state_check_interval"]
|
self._state_check_interval = settings["state_check_interval"]
|
||||||
|
if "led_skip_start" in settings:
|
||||||
|
self._led_skip_start = max(0, settings["led_skip_start"])
|
||||||
|
if "led_skip_end" in settings:
|
||||||
|
self._led_skip_end = max(0, settings["led_skip_end"])
|
||||||
logger.info(f"Updated settings for target {self._target_id}")
|
logger.info(f"Updated settings for target {self._target_id}")
|
||||||
|
|
||||||
def update_device(self, device_id: str) -> None:
|
def update_device(self, device_id: str) -> None:
|
||||||
@@ -293,6 +302,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
"display_index": self._resolved_display_index,
|
"display_index": self._resolved_display_index,
|
||||||
"overlay_active": self._overlay_active,
|
"overlay_active": self._overlay_active,
|
||||||
"needs_keepalive": self._needs_keepalive,
|
"needs_keepalive": self._needs_keepalive,
|
||||||
|
"led_skip_start": self._led_skip_start,
|
||||||
|
"led_skip_end": self._led_skip_end,
|
||||||
"last_update": metrics.last_update,
|
"last_update": metrics.last_update,
|
||||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||||
}
|
}
|
||||||
@@ -404,10 +415,24 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
])
|
])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _apply_led_skip(self, colors: np.ndarray) -> np.ndarray:
|
||||||
|
"""Pad color array with black at start/end for skipped LEDs."""
|
||||||
|
s, e = self._led_skip_start, self._led_skip_end
|
||||||
|
if s <= 0 and e <= 0:
|
||||||
|
return colors
|
||||||
|
channels = colors.shape[1] if colors.ndim == 2 else 3
|
||||||
|
parts = []
|
||||||
|
if s > 0:
|
||||||
|
parts.append(np.zeros((s, channels), dtype=np.uint8))
|
||||||
|
parts.append(colors)
|
||||||
|
if e > 0:
|
||||||
|
parts.append(np.zeros((e, channels), dtype=np.uint8))
|
||||||
|
return np.vstack(parts)
|
||||||
|
|
||||||
async def _processing_loop(self) -> None:
|
async def _processing_loop(self) -> None:
|
||||||
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
|
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
|
||||||
stream = self._color_strip_stream
|
stream = self._color_strip_stream
|
||||||
standby_interval = self._standby_interval
|
keepalive_interval = self._keepalive_interval
|
||||||
|
|
||||||
fps_samples: collections.deque = collections.deque(maxlen=10)
|
fps_samples: collections.deque = collections.deque(maxlen=10)
|
||||||
send_timestamps: collections.deque = collections.deque()
|
send_timestamps: collections.deque = collections.deque()
|
||||||
@@ -415,6 +440,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
last_send_time = 0.0
|
last_send_time = 0.0
|
||||||
prev_frame_time_stamp = time.perf_counter()
|
prev_frame_time_stamp = time.perf_counter()
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
effective_leds = max(1, (device_info.led_count if device_info else 0) - self._led_skip_start - self._led_skip_end)
|
||||||
# Short re-poll interval when the animation thread hasn't produced a new
|
# Short re-poll interval when the animation thread hasn't produced a new
|
||||||
# frame yet. The animation thread and this loop both target the same FPS
|
# frame yet. The animation thread and this loop both target the same FPS
|
||||||
# but are unsynchronised; without a short re-poll the loop can miss a
|
# but are unsynchronised; without a short re-poll the loop can miss a
|
||||||
@@ -471,12 +497,13 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
if colors is prev_colors:
|
if colors is prev_colors:
|
||||||
# Same frame — send keepalive if interval elapsed (only for devices that need it)
|
# Same frame — send keepalive if interval elapsed (only for devices that need it)
|
||||||
if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
|
if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= keepalive_interval:
|
||||||
if not self._is_running or self._led_client is None:
|
if not self._is_running or self._led_client is None:
|
||||||
break
|
break
|
||||||
kc = prev_colors
|
kc = prev_colors
|
||||||
if device_info and device_info.led_count > 0:
|
if device_info and device_info.led_count > 0:
|
||||||
kc = self._fit_to_device(kc, device_info.led_count)
|
kc = self._fit_to_device(kc, effective_leds)
|
||||||
|
kc = self._apply_led_skip(kc)
|
||||||
send_colors = self._apply_brightness(kc, device_info)
|
send_colors = self._apply_brightness(kc, device_info)
|
||||||
if self._led_client.supports_fast_send:
|
if self._led_client.supports_fast_send:
|
||||||
self._led_client.send_pixels_fast(send_colors)
|
self._led_client.send_pixels_fast(send_colors)
|
||||||
@@ -496,9 +523,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
prev_colors = colors
|
prev_colors = colors
|
||||||
|
|
||||||
# Fit to this device's LED count (stream may be shared)
|
# Fit to effective LED count (excluding skipped) then pad with blacks
|
||||||
if device_info and device_info.led_count > 0:
|
if device_info and device_info.led_count > 0:
|
||||||
colors = self._fit_to_device(colors, device_info.led_count)
|
colors = self._fit_to_device(colors, effective_leds)
|
||||||
|
colors = self._apply_led_skip(colors)
|
||||||
|
|
||||||
# Apply device software brightness
|
# Apply device software brightness
|
||||||
send_colors = self._apply_brightness(colors, device_info)
|
send_colors = self._apply_brightness(colors, device_info)
|
||||||
|
|||||||
@@ -54,105 +54,6 @@ processor_manager = ProcessorManager(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_devices_to_targets():
|
|
||||||
"""One-time migration: create picture targets from legacy device settings.
|
|
||||||
|
|
||||||
If the target store is empty and any device has legacy picture_source_id
|
|
||||||
or settings in raw JSON, migrate them to WledPictureTargets.
|
|
||||||
"""
|
|
||||||
if picture_target_store.count() > 0:
|
|
||||||
return # Already have targets, skip migration
|
|
||||||
|
|
||||||
raw = device_store.load_raw()
|
|
||||||
devices_raw = raw.get("devices", {})
|
|
||||||
if not devices_raw:
|
|
||||||
return
|
|
||||||
|
|
||||||
migrated = 0
|
|
||||||
for device_id, device_data in devices_raw.items():
|
|
||||||
legacy_source_id = device_data.get("picture_source_id", "")
|
|
||||||
|
|
||||||
if not legacy_source_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
device_name = device_data.get("name", device_id)
|
|
||||||
target_name = f"{device_name} Target"
|
|
||||||
|
|
||||||
try:
|
|
||||||
target = picture_target_store.create_target(
|
|
||||||
name=target_name,
|
|
||||||
target_type="wled",
|
|
||||||
device_id=device_id,
|
|
||||||
description=f"Auto-migrated from device {device_name}",
|
|
||||||
)
|
|
||||||
migrated += 1
|
|
||||||
logger.info(f"Migrated device {device_id} -> target {target.id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to migrate device {device_id} to target: {e}")
|
|
||||||
|
|
||||||
if migrated > 0:
|
|
||||||
logger.info(f"Migration complete: created {migrated} picture target(s) from legacy device settings")
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_targets_to_color_strips():
|
|
||||||
"""One-time migration: create ColorStripSources from legacy WledPictureTarget data.
|
|
||||||
|
|
||||||
For each WledPictureTarget that has a legacy _legacy_picture_source_id (from old JSON)
|
|
||||||
but no color_strip_source_id, create a ColorStripSource and link it.
|
|
||||||
"""
|
|
||||||
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
|
||||||
from wled_controller.core.capture.calibration import create_default_calibration
|
|
||||||
|
|
||||||
migrated = 0
|
|
||||||
for target in picture_target_store.get_all_targets():
|
|
||||||
if not isinstance(target, WledPictureTarget):
|
|
||||||
continue
|
|
||||||
if target.color_strip_source_id:
|
|
||||||
continue # already migrated
|
|
||||||
if not target._legacy_picture_source_id:
|
|
||||||
continue # no legacy source to migrate
|
|
||||||
|
|
||||||
legacy_settings = target._legacy_settings or {}
|
|
||||||
|
|
||||||
# Try to get calibration from device (old location)
|
|
||||||
device = device_store.get_device(target.device_id) if target.device_id else None
|
|
||||||
calibration = getattr(device, "_legacy_calibration", None) if device else None
|
|
||||||
if calibration is None:
|
|
||||||
calibration = create_default_calibration(0)
|
|
||||||
|
|
||||||
css_name = f"{target.name} Strip"
|
|
||||||
# Ensure unique name
|
|
||||||
existing_names = {s.name for s in color_strip_store.get_all_sources()}
|
|
||||||
if css_name in existing_names:
|
|
||||||
css_name = f"{target.name} Strip (migrated)"
|
|
||||||
|
|
||||||
try:
|
|
||||||
css = color_strip_store.create_source(
|
|
||||||
name=css_name,
|
|
||||||
source_type="picture",
|
|
||||||
picture_source_id=target._legacy_picture_source_id,
|
|
||||||
fps=legacy_settings.get("fps", 30),
|
|
||||||
brightness=legacy_settings.get("brightness", 1.0),
|
|
||||||
smoothing=legacy_settings.get("smoothing", 0.3),
|
|
||||||
interpolation_mode=legacy_settings.get("interpolation_mode", "average"),
|
|
||||||
calibration=calibration,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update target to reference the new CSS
|
|
||||||
target.color_strip_source_id = css.id
|
|
||||||
target.standby_interval = legacy_settings.get("standby_interval", 1.0)
|
|
||||||
target.state_check_interval = legacy_settings.get("state_check_interval", 30)
|
|
||||||
picture_target_store._save()
|
|
||||||
|
|
||||||
migrated += 1
|
|
||||||
logger.info(f"Migrated target {target.id} -> CSS {css.id} ({css_name})")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to migrate target {target.id} to CSS: {e}")
|
|
||||||
|
|
||||||
if migrated > 0:
|
|
||||||
logger.info(f"CSS migration complete: created {migrated} color strip source(s) from legacy targets")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan manager.
|
"""Application lifespan manager.
|
||||||
@@ -182,10 +83,6 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"Authorized clients: {client_labels}")
|
logger.info(f"Authorized clients: {client_labels}")
|
||||||
logger.info("All API requests require valid Bearer token authentication")
|
logger.info("All API requests require valid Bearer token authentication")
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
_migrate_devices_to_targets()
|
|
||||||
_migrate_targets_to_color_strips()
|
|
||||||
|
|
||||||
# Create profile engine (needs processor_manager)
|
# Create profile engine (needs processor_manager)
|
||||||
profile_engine = ProfileEngine(profile_store, processor_manager)
|
profile_engine = ProfileEngine(profile_store, processor_manager)
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,26 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-field input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.fps-hint {
|
.fps-hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ class TargetEditorModal extends Modal {
|
|||||||
device: document.getElementById('target-editor-device').value,
|
device: document.getElementById('target-editor-device').value,
|
||||||
css: document.getElementById('target-editor-css').value,
|
css: document.getElementById('target-editor-css').value,
|
||||||
fps: document.getElementById('target-editor-fps').value,
|
fps: document.getElementById('target-editor-fps').value,
|
||||||
standby_interval: document.getElementById('target-editor-keepalive-interval').value,
|
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
||||||
|
led_skip_start: document.getElementById('target-editor-skip-start').value,
|
||||||
|
led_skip_end: document.getElementById('target-editor-skip-end').value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,8 +181,10 @@ export async function showTargetEditor(targetId = null) {
|
|||||||
const fps = target.fps ?? 30;
|
const fps = target.fps ?? 30;
|
||||||
document.getElementById('target-editor-fps').value = fps;
|
document.getElementById('target-editor-fps').value = fps;
|
||||||
document.getElementById('target-editor-fps-value').textContent = fps;
|
document.getElementById('target-editor-fps-value').textContent = fps;
|
||||||
document.getElementById('target-editor-keepalive-interval').value = target.standby_interval ?? 1.0;
|
document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0;
|
||||||
document.getElementById('target-editor-keepalive-interval-value').textContent = target.standby_interval ?? 1.0;
|
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
|
||||||
|
document.getElementById('target-editor-skip-start').value = target.led_skip_start ?? 0;
|
||||||
|
document.getElementById('target-editor-skip-end').value = target.led_skip_end ?? 0;
|
||||||
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
||||||
} else {
|
} else {
|
||||||
// Creating new target — first option is selected by default
|
// Creating new target — first option is selected by default
|
||||||
@@ -190,6 +194,8 @@ export async function showTargetEditor(targetId = null) {
|
|||||||
document.getElementById('target-editor-fps-value').textContent = '30';
|
document.getElementById('target-editor-fps-value').textContent = '30';
|
||||||
document.getElementById('target-editor-keepalive-interval').value = 1.0;
|
document.getElementById('target-editor-keepalive-interval').value = 1.0;
|
||||||
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
|
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
|
||||||
|
document.getElementById('target-editor-skip-start').value = 0;
|
||||||
|
document.getElementById('target-editor-skip-end').value = 0;
|
||||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +239,8 @@ export async function saveTargetEditor() {
|
|||||||
const deviceId = document.getElementById('target-editor-device').value;
|
const deviceId = document.getElementById('target-editor-device').value;
|
||||||
const cssId = document.getElementById('target-editor-css').value;
|
const cssId = document.getElementById('target-editor-css').value;
|
||||||
const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
|
const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
|
||||||
|
const ledSkipStart = parseInt(document.getElementById('target-editor-skip-start').value) || 0;
|
||||||
|
const ledSkipEnd = parseInt(document.getElementById('target-editor-skip-end').value) || 0;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
targetEditorModal.showError(t('targets.error.name_required'));
|
targetEditorModal.showError(t('targets.error.name_required'));
|
||||||
@@ -246,7 +254,9 @@ export async function saveTargetEditor() {
|
|||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
color_strip_source_id: cssId,
|
color_strip_source_id: cssId,
|
||||||
fps,
|
fps,
|
||||||
standby_interval: standbyInterval,
|
keepalive_interval: standbyInterval,
|
||||||
|
led_skip_start: ledSkipStart,
|
||||||
|
led_skip_end: ledSkipEnd,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -373,6 +373,10 @@
|
|||||||
"targets.interpolation.dominant": "Dominant",
|
"targets.interpolation.dominant": "Dominant",
|
||||||
"targets.smoothing": "Smoothing:",
|
"targets.smoothing": "Smoothing:",
|
||||||
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||||
|
"targets.led_skip": "LED Skip:",
|
||||||
|
"targets.led_skip.hint": "Number of LEDs at the start and end of the strip to keep black. Color sources will render only across the active (non-skipped) LEDs.",
|
||||||
|
"targets.led_skip_start": "Start:",
|
||||||
|
"targets.led_skip_end": "End:",
|
||||||
"targets.keepalive_interval": "Keep Alive Interval:",
|
"targets.keepalive_interval": "Keep Alive Interval:",
|
||||||
"targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)",
|
"targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)",
|
||||||
"targets.created": "Target created successfully",
|
"targets.created": "Target created successfully",
|
||||||
|
|||||||
@@ -373,6 +373,10 @@
|
|||||||
"targets.interpolation.dominant": "Доминантный",
|
"targets.interpolation.dominant": "Доминантный",
|
||||||
"targets.smoothing": "Сглаживание:",
|
"targets.smoothing": "Сглаживание:",
|
||||||
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
||||||
|
"targets.led_skip": "Пропуск LED:",
|
||||||
|
"targets.led_skip.hint": "Количество светодиодов в начале и конце ленты, которые остаются чёрными. Источники цвета будут рендериться только на активных (непропущенных) LED.",
|
||||||
|
"targets.led_skip_start": "Начало:",
|
||||||
|
"targets.led_skip_end": "Конец:",
|
||||||
"targets.keepalive_interval": "Интервал поддержания связи:",
|
"targets.keepalive_interval": "Интервал поддержания связи:",
|
||||||
"targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)",
|
"targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)",
|
||||||
"targets.created": "Цель успешно создана",
|
"targets.created": "Цель успешно создана",
|
||||||
|
|||||||
@@ -91,9 +91,7 @@ class KeyColorsPictureTarget(PictureTarget):
|
|||||||
|
|
||||||
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
|
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
|
||||||
settings=None, key_colors_settings=None, description=None,
|
settings=None, key_colors_settings=None, description=None,
|
||||||
# WledPictureTarget-specific params — accepted but ignored:
|
**_kwargs) -> None:
|
||||||
color_strip_source_id=None, standby_interval=None,
|
|
||||||
state_check_interval=None) -> None:
|
|
||||||
"""Apply mutable field updates for KC targets."""
|
"""Apply mutable field updates for KC targets."""
|
||||||
super().update_fields(name=name, description=description)
|
super().update_fields(name=name, description=description)
|
||||||
if picture_source_id is not None:
|
if picture_source_id is not None:
|
||||||
|
|||||||
@@ -103,8 +103,10 @@ class PictureTargetStore:
|
|||||||
device_id: str = "",
|
device_id: str = "",
|
||||||
color_strip_source_id: str = "",
|
color_strip_source_id: str = "",
|
||||||
fps: int = 30,
|
fps: int = 30,
|
||||||
standby_interval: float = 1.0,
|
keepalive_interval: float = 1.0,
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
|
led_skip_start: int = 0,
|
||||||
|
led_skip_end: int = 0,
|
||||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
# Legacy params — accepted but ignored for backward compat
|
# Legacy params — accepted but ignored for backward compat
|
||||||
@@ -118,7 +120,7 @@ class PictureTargetStore:
|
|||||||
target_type: Target type ("led", "wled", "key_colors")
|
target_type: Target type ("led", "wled", "key_colors")
|
||||||
device_id: WLED device ID (for led targets)
|
device_id: WLED device ID (for led targets)
|
||||||
color_strip_source_id: Color strip source ID (for led targets)
|
color_strip_source_id: Color strip source ID (for led targets)
|
||||||
standby_interval: Keepalive interval in seconds (for led targets)
|
keepalive_interval: Keepalive interval in seconds (for led targets)
|
||||||
state_check_interval: State check interval in seconds (for led targets)
|
state_check_interval: State check interval in seconds (for led targets)
|
||||||
key_colors_settings: Key colors settings (for key_colors targets)
|
key_colors_settings: Key colors settings (for key_colors targets)
|
||||||
description: Optional description
|
description: Optional description
|
||||||
@@ -148,8 +150,10 @@ class PictureTargetStore:
|
|||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
color_strip_source_id=color_strip_source_id,
|
||||||
fps=fps,
|
fps=fps,
|
||||||
standby_interval=standby_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
|
led_skip_start=led_skip_start,
|
||||||
|
led_skip_end=led_skip_end,
|
||||||
description=description,
|
description=description,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -181,8 +185,10 @@ class PictureTargetStore:
|
|||||||
device_id: Optional[str] = None,
|
device_id: Optional[str] = None,
|
||||||
color_strip_source_id: Optional[str] = None,
|
color_strip_source_id: Optional[str] = None,
|
||||||
fps: Optional[int] = None,
|
fps: Optional[int] = None,
|
||||||
standby_interval: Optional[float] = None,
|
keepalive_interval: Optional[float] = None,
|
||||||
state_check_interval: Optional[int] = None,
|
state_check_interval: Optional[int] = None,
|
||||||
|
led_skip_start: Optional[int] = None,
|
||||||
|
led_skip_end: Optional[int] = None,
|
||||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
# Legacy params — accepted but ignored
|
# Legacy params — accepted but ignored
|
||||||
@@ -210,8 +216,10 @@ class PictureTargetStore:
|
|||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
color_strip_source_id=color_strip_source_id,
|
color_strip_source_id=color_strip_source_id,
|
||||||
fps=fps,
|
fps=fps,
|
||||||
standby_interval=standby_interval,
|
keepalive_interval=keepalive_interval,
|
||||||
state_check_interval=state_check_interval,
|
state_check_interval=state_check_interval,
|
||||||
|
led_skip_start=led_skip_start,
|
||||||
|
led_skip_end=led_skip_end,
|
||||||
key_colors_settings=key_colors_settings,
|
key_colors_settings=key_colors_settings,
|
||||||
description=description,
|
description=description,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""LED picture target — sends a color strip source to an LED device."""
|
"""LED picture target — sends a color strip source to an LED device."""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from wled_controller.storage.picture_target import PictureTarget
|
from wled_controller.storage.picture_target import PictureTarget
|
||||||
|
|
||||||
@@ -20,12 +19,10 @@ class WledPictureTarget(PictureTarget):
|
|||||||
device_id: str = ""
|
device_id: str = ""
|
||||||
color_strip_source_id: str = ""
|
color_strip_source_id: str = ""
|
||||||
fps: int = 30 # target send FPS (1-90)
|
fps: int = 30 # target send FPS (1-90)
|
||||||
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||||
|
led_skip_start: int = 0 # first N LEDs forced to black
|
||||||
# Legacy fields — populated from old JSON data during migration; not written back
|
led_skip_end: int = 0 # last M LEDs forced to black
|
||||||
_legacy_picture_source_id: str = field(default="", repr=False, compare=False)
|
|
||||||
_legacy_settings: Optional[dict] = field(default=None, repr=False, compare=False)
|
|
||||||
|
|
||||||
def register_with_manager(self, manager) -> None:
|
def register_with_manager(self, manager) -> None:
|
||||||
"""Register this WLED target with the processor manager."""
|
"""Register this WLED target with the processor manager."""
|
||||||
@@ -35,8 +32,10 @@ class WledPictureTarget(PictureTarget):
|
|||||||
device_id=self.device_id,
|
device_id=self.device_id,
|
||||||
color_strip_source_id=self.color_strip_source_id,
|
color_strip_source_id=self.color_strip_source_id,
|
||||||
fps=self.fps,
|
fps=self.fps,
|
||||||
standby_interval=self.standby_interval,
|
keepalive_interval=self.keepalive_interval,
|
||||||
state_check_interval=self.state_check_interval,
|
state_check_interval=self.state_check_interval,
|
||||||
|
led_skip_start=self.led_skip_start,
|
||||||
|
led_skip_end=self.led_skip_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None:
|
def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None:
|
||||||
@@ -44,8 +43,10 @@ class WledPictureTarget(PictureTarget):
|
|||||||
if settings_changed:
|
if settings_changed:
|
||||||
manager.update_target_settings(self.id, {
|
manager.update_target_settings(self.id, {
|
||||||
"fps": self.fps,
|
"fps": self.fps,
|
||||||
"standby_interval": self.standby_interval,
|
"keepalive_interval": self.keepalive_interval,
|
||||||
"state_check_interval": self.state_check_interval,
|
"state_check_interval": self.state_check_interval,
|
||||||
|
"led_skip_start": self.led_skip_start,
|
||||||
|
"led_skip_end": self.led_skip_end,
|
||||||
})
|
})
|
||||||
if source_changed:
|
if source_changed:
|
||||||
manager.update_target_color_strip_source(self.id, self.color_strip_source_id)
|
manager.update_target_color_strip_source(self.id, self.color_strip_source_id)
|
||||||
@@ -53,10 +54,9 @@ class WledPictureTarget(PictureTarget):
|
|||||||
manager.update_target_device(self.id, self.device_id)
|
manager.update_target_device(self.id, self.device_id)
|
||||||
|
|
||||||
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
|
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
|
||||||
fps=None, standby_interval=None, state_check_interval=None,
|
fps=None, keepalive_interval=None, state_check_interval=None,
|
||||||
# Legacy params accepted but ignored to keep base class compat:
|
led_skip_start=None, led_skip_end=None,
|
||||||
picture_source_id=None, settings=None,
|
description=None, **_kwargs) -> None:
|
||||||
key_colors_settings=None, description=None) -> None:
|
|
||||||
"""Apply mutable field updates for WLED targets."""
|
"""Apply mutable field updates for WLED targets."""
|
||||||
super().update_fields(name=name, description=description)
|
super().update_fields(name=name, description=description)
|
||||||
if device_id is not None:
|
if device_id is not None:
|
||||||
@@ -65,10 +65,14 @@ class WledPictureTarget(PictureTarget):
|
|||||||
self.color_strip_source_id = color_strip_source_id
|
self.color_strip_source_id = color_strip_source_id
|
||||||
if fps is not None:
|
if fps is not None:
|
||||||
self.fps = fps
|
self.fps = fps
|
||||||
if standby_interval is not None:
|
if keepalive_interval is not None:
|
||||||
self.standby_interval = standby_interval
|
self.keepalive_interval = keepalive_interval
|
||||||
if state_check_interval is not None:
|
if state_check_interval is not None:
|
||||||
self.state_check_interval = state_check_interval
|
self.state_check_interval = state_check_interval
|
||||||
|
if led_skip_start is not None:
|
||||||
|
self.led_skip_start = led_skip_start
|
||||||
|
if led_skip_end is not None:
|
||||||
|
self.led_skip_end = led_skip_end
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_picture_source(self) -> bool:
|
def has_picture_source(self) -> bool:
|
||||||
@@ -80,31 +84,27 @@ class WledPictureTarget(PictureTarget):
|
|||||||
d["device_id"] = self.device_id
|
d["device_id"] = self.device_id
|
||||||
d["color_strip_source_id"] = self.color_strip_source_id
|
d["color_strip_source_id"] = self.color_strip_source_id
|
||||||
d["fps"] = self.fps
|
d["fps"] = self.fps
|
||||||
d["standby_interval"] = self.standby_interval
|
d["keepalive_interval"] = self.keepalive_interval
|
||||||
d["state_check_interval"] = self.state_check_interval
|
d["state_check_interval"] = self.state_check_interval
|
||||||
|
d["led_skip_start"] = self.led_skip_start
|
||||||
|
d["led_skip_end"] = self.led_skip_end
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "WledPictureTarget":
|
def from_dict(cls, data: dict) -> "WledPictureTarget":
|
||||||
"""Create from dictionary. Reads legacy picture_source_id/settings for migration."""
|
"""Create from dictionary."""
|
||||||
obj = cls(
|
return cls(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
target_type="led",
|
target_type="led",
|
||||||
device_id=data.get("device_id", ""),
|
device_id=data.get("device_id", ""),
|
||||||
color_strip_source_id=data.get("color_strip_source_id", ""),
|
color_strip_source_id=data.get("color_strip_source_id", ""),
|
||||||
fps=data.get("fps", 30),
|
fps=data.get("fps", 30),
|
||||||
standby_interval=data.get("standby_interval", 1.0),
|
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
|
||||||
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||||
|
led_skip_start=data.get("led_skip_start", 0),
|
||||||
|
led_skip_end=data.get("led_skip_end", 0),
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Preserve legacy fields for migration — never written back by to_dict()
|
|
||||||
obj._legacy_picture_source_id = data.get("picture_source_id", "")
|
|
||||||
settings_data = data.get("settings", {})
|
|
||||||
if settings_data:
|
|
||||||
obj._legacy_settings = settings_data
|
|
||||||
|
|
||||||
return obj
|
|
||||||
|
|||||||
@@ -48,6 +48,24 @@
|
|||||||
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
|
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="target-editor-skip-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="targets.led_skip">LED Skip:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="targets.led_skip.hint">Number of LEDs at the start and end of the strip to keep black. Color sources will render only across the active (non-skipped) LEDs.</small>
|
||||||
|
<div class="inline-fields">
|
||||||
|
<div class="inline-field">
|
||||||
|
<label for="target-editor-skip-start" data-i18n="targets.led_skip_start">Start:</label>
|
||||||
|
<input type="number" id="target-editor-skip-start" min="0" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="inline-field">
|
||||||
|
<label for="target-editor-skip-end" data-i18n="targets.led_skip_end">End:</label>
|
||||||
|
<input type="number" id="target-editor-skip-end" min="0" value="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="target-editor-keepalive-group">
|
<div class="form-group" id="target-editor-keepalive-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="target-editor-keepalive-interval">
|
<label for="target-editor-keepalive-interval">
|
||||||
|
|||||||
Reference in New Issue
Block a user