diff --git a/server/src/ledgrab/api/routes/output_targets.py b/server/src/ledgrab/api/routes/output_targets.py index 1e75820..7b677d8 100644 --- a/server/src/ledgrab/api/routes/output_targets.py +++ b/server/src/ledgrab/api/routes/output_targets.py @@ -70,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse min_brightness_threshold=target.min_brightness_threshold.to_dict(), adaptive_fps=target.adaptive_fps, protocol=target.protocol, + max_milliamps=target.max_milliamps, + milliamps_per_led=target.milliamps_per_led, description=target.description, tags=target.tags, icon=getattr(target, "icon", "") or "", @@ -302,6 +304,8 @@ async def create_target( min_brightness_threshold=data.min_brightness_threshold, adaptive_fps=data.adaptive_fps, protocol=data.protocol, + max_milliamps=data.max_milliamps, + milliamps_per_led=data.milliamps_per_led, ) case HALightOutputTargetCreate(): if data.source_kind == "color_vs": @@ -464,6 +468,8 @@ async def update_target( min_brightness_threshold=data.min_brightness_threshold, adaptive_fps=data.adaptive_fps, protocol=data.protocol, + max_milliamps=data.max_milliamps, + milliamps_per_led=data.milliamps_per_led, ) css_changed = data.color_strip_source_id is not None brightness_changed = data.brightness is not None @@ -476,6 +482,8 @@ async def update_target( data.min_brightness_threshold, data.adaptive_fps, data.brightness, + data.max_milliamps, + data.milliamps_per_led, ) ) device_changed = data.device_id is not None diff --git a/server/src/ledgrab/api/schemas/output_targets.py b/server/src/ledgrab/api/schemas/output_targets.py index 4d3a34c..e7663f7 100644 --- a/server/src/ledgrab/api/schemas/output_targets.py +++ b/server/src/ledgrab/api/schemas/output_targets.py @@ -92,6 +92,10 @@ class LedOutputTargetResponse(_OutputTargetResponseBase): default=False, description="Auto-reduce FPS when device is unresponsive" ) protocol: str = Field(default="ddp", description="Send protocol (ddp or http)") + max_milliamps: int = Field( + default=0, description="ABL: PSU current budget in mA (0 = unlimited)" + ) + milliamps_per_led: int = Field(default=55, description="ABL: full-white draw of one LED in mA") class HALightOutputTargetResponse(_OutputTargetResponseBase): @@ -236,6 +240,18 @@ class LedOutputTargetCreate(_OutputTargetCreateBase): pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)", ) + max_milliamps: int = Field( + default=0, + ge=0, + le=200000, + description="Automatic brightness limiting: PSU current budget in mA (0 = unlimited)", + ) + milliamps_per_led: int = Field( + default=55, + ge=1, + le=200, + description="ABL: estimated full-white draw of a single LED, in mA", + ) class HALightOutputTargetCreate(_OutputTargetCreateBase): @@ -372,6 +388,12 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase): protocol: str | None = Field( None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)" ) + max_milliamps: int | None = Field( + None, ge=0, le=200000, description="ABL: PSU current budget in mA (0 = unlimited)" + ) + milliamps_per_led: int | None = Field( + None, ge=1, le=200, description="ABL: full-white draw of one LED in mA" + ) class HALightOutputTargetUpdate(_OutputTargetUpdateBase): diff --git a/server/src/ledgrab/core/processing/power_limit.py b/server/src/ledgrab/core/processing/power_limit.py new file mode 100644 index 0000000..34fb825 --- /dev/null +++ b/server/src/ledgrab/core/processing/power_limit.py @@ -0,0 +1,58 @@ +"""Automatic brightness limiting (ABL) — keep a strip within a PSU current budget. + +Estimates the current an addressable LED strip would draw for a frame of +already-brightness-scaled RGB bytes and, if it exceeds the configured budget, +returns a uniform scale factor to bring it back under budget. This prevents the +classic under-spec'd-PSU failure mode: a full-white scene browning out the rail +(voltage sag -> red/orange shift, flicker, controller resets) — which reads to a +new user as "this software is broken". + +Model: one addressable LED at full white ``(255, 255, 255)`` draws +``milliamps_per_led`` mA, and current scales linearly with the sum of channel +values, so a frame's draw is:: + + estimated_ma = sum(channel_bytes) * milliamps_per_led / (255 * 3) + +(``255 * 3 = 765`` channel-units == one LED at full white.) Standby/idle current +is intentionally ignored: the limiter only needs to catch the high-draw frames +that cause brownouts, and the default 55 mA/LED already carries real-world +headroom. The same convention as WLED's "maximum current" setting. +""" + +from __future__ import annotations + +import numpy as np + +# Channel units in one LED at full white (R + G + B = 255 * 3). +_FULL_WHITE_UNITS = 765.0 + +# Typical full-white draw of a single WS2812/SK6812-class LED, in mA. +DEFAULT_MILLIAMPS_PER_LED = 55 + + +def estimate_current_ma(colors: np.ndarray, milliamps_per_led: int) -> float: + """Estimate strip draw (mA) for already-brightness-scaled RGB bytes. + + ``colors`` is an ``(N, 3)`` uint8 array of the values actually sent to the + strip. Full white over ``N`` LEDs returns ``N * milliamps_per_led``. + """ + if milliamps_per_led <= 0 or colors.size == 0: + return 0.0 + channel_sum = float(int(colors.sum())) + return channel_sum * milliamps_per_led / _FULL_WHITE_UNITS + + +def power_limit_scale(colors: np.ndarray, max_milliamps: int, milliamps_per_led: int) -> float: + """Return a scale in ``(0, 1]`` that keeps estimated draw within budget. + + Returns ``1.0`` when limiting is disabled (``max_milliamps <= 0``) or the + frame is already within budget. Because current is linear in the channel + values, scaling every pixel by ``max_milliamps / estimated`` lands the frame + exactly on the budget. + """ + if max_milliamps <= 0 or milliamps_per_led <= 0: + return 1.0 + estimated = estimate_current_ma(colors, milliamps_per_led) + if estimated <= max_milliamps: + return 1.0 + return max_milliamps / estimated diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py index 65967d4..5ddeda3 100644 --- a/server/src/ledgrab/core/processing/processor_manager.py +++ b/server/src/ledgrab/core/processing/processor_manager.py @@ -407,6 +407,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) min_brightness_threshold: int = 0, adaptive_fps: bool = False, protocol: str = "ddp", + max_milliamps: int = 0, + milliamps_per_led: int = 55, ): """Register a WLED target processor.""" if target_id in self._processors: @@ -425,6 +427,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) min_brightness_threshold=min_brightness_threshold, adaptive_fps=adaptive_fps, protocol=protocol, + max_milliamps=max_milliamps, + milliamps_per_led=milliamps_per_led, ctx=self._build_context(), ) self._processors[target_id] = proc diff --git a/server/src/ledgrab/core/processing/wled_target_processor.py b/server/src/ledgrab/core/processing/wled_target_processor.py index 6fad563..7060aaa 100644 --- a/server/src/ledgrab/core/processing/wled_target_processor.py +++ b/server/src/ledgrab/core/processing/wled_target_processor.py @@ -17,6 +17,7 @@ from ledgrab.core.devices.led_client import ( get_device_capabilities, ) from ledgrab.core.capture.screen_capture import get_available_displays +from ledgrab.core.processing.power_limit import DEFAULT_MILLIAMPS_PER_LED, power_limit_scale from ledgrab.core.processing.target_processor import ( ProcessingMetrics, TargetContext, @@ -62,6 +63,8 @@ class WledTargetProcessor(TargetProcessor): min_brightness_threshold: int = 0, adaptive_fps: bool = False, protocol: str = "ddp", + max_milliamps: int = 0, + milliamps_per_led: int = 55, ctx: TargetContext = None, ): from ledgrab.storage.bindable import BindableFloat, bfloat @@ -81,6 +84,13 @@ class WledTargetProcessor(TargetProcessor): self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0)) self._adaptive_fps = adaptive_fps self._protocol = protocol + # Automatic brightness limiting (ABL). 0 mA budget = disabled. + self._max_milliamps = max(0, int(max_milliamps or 0)) + self._milliamps_per_led = max(1, int(milliamps_per_led or DEFAULT_MILLIAMPS_PER_LED)) + # Reusable scratch for in-place power scaling (allocated on first use). + self._power_u16: np.ndarray | None = None + self._power_out: np.ndarray | None = None + self._power_n = 0 # Adaptive FPS / liveness probe runtime state self._effective_fps: int = self._target_fps @@ -313,6 +323,12 @@ class WledTargetProcessor(TargetProcessor): self._adaptive_fps = settings["adaptive_fps"] if not self._adaptive_fps: self._effective_fps = self._target_fps + if "max_milliamps" in settings: + self._max_milliamps = max(0, int(settings["max_milliamps"] or 0)) + if "milliamps_per_led" in settings: + self._milliamps_per_led = max( + 1, int(settings["milliamps_per_led"] or DEFAULT_MILLIAMPS_PER_LED) + ) logger.info(f"Updated settings for target {self._target_id}") def update_device(self, device_id: str) -> None: @@ -787,8 +803,33 @@ class WledTargetProcessor(TargetProcessor): np.copyto(out, blend, casting="unsafe") # float32 → uint8 return out + def _apply_power_limit(self, colors: np.ndarray) -> np.ndarray: + """Scale ``colors`` down to stay within the PSU current budget (ABL). + + Returns ``colors`` unchanged when limiting is disabled or the frame is + already within budget; otherwise returns a scaled copy in a reusable + scratch buffer (the input is never mutated — it may be a shared frame). + """ + if self._max_milliamps <= 0: + return colors + scale = power_limit_scale(colors, self._max_milliamps, self._milliamps_per_led) + if scale >= 1.0: + return colors + factor = int(scale * 256) # 0..255 fixed-point multiplier + n = len(colors) + if self._power_u16 is None or self._power_n != n: + self._power_n = n + self._power_u16 = np.empty((n, 3), dtype=np.uint16) + self._power_out = np.empty((n, 3), dtype=np.uint8) + np.copyto(self._power_u16, colors, casting="unsafe") + self._power_u16 *= factor + self._power_u16 >>= 8 + np.copyto(self._power_out, self._power_u16, casting="unsafe") + return self._power_out + async def _send_to_device(self, send_colors: np.ndarray) -> float: """Send colors to LED device and return send time in ms.""" + send_colors = self._apply_power_limit(send_colors) t_start = time.perf_counter() if self._led_client.supports_fast_send: self._led_client.send_pixels_fast(send_colors) diff --git a/server/src/ledgrab/static/js/features/targets.ts b/server/src/ledgrab/static/js/features/targets.ts index d1fe26a..0514cf9 100644 --- a/server/src/ledgrab/static/js/features/targets.ts +++ b/server/src/ledgrab/static/js/features/targets.ts @@ -171,6 +171,8 @@ class TargetEditorModal extends Modal { fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30', keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value, adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked, + max_milliamps: (document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value, + milliamps_per_led: (document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value, tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []), }; } @@ -401,6 +403,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp'; + (document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(target.max_milliamps ?? 0); + (document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(target.milliamps_per_led ?? 55); _populateCssDropdown(target.color_strip_source_id || ''); _ensureBrightnessWidget().setValue(target.brightness ?? 1.0); @@ -419,6 +423,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp'; + (document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(cloneData.max_milliamps ?? 0); + (document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(cloneData.milliamps_per_led ?? 55); _populateCssDropdown(cloneData.color_strip_source_id || ''); _ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0); @@ -435,6 +441,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp'; + (document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = '0'; + (document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = '55'; _populateCssDropdown(''); _ensureBrightnessWidget().setValue(1.0); @@ -515,6 +523,8 @@ export async function saveTargetEditor() { const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked; const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value; + const maxMilliamps = Math.max(0, Math.round(Number((document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value) || 0)); + const milliampsPerLed = Math.max(1, Math.round(Number((document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value) || 55)); const payload: any = { name, @@ -526,6 +536,8 @@ export async function saveTargetEditor() { keepalive_interval: standbyInterval, adaptive_fps: adaptiveFps, protocol, + max_milliamps: maxMilliamps, + milliamps_per_led: milliampsPerLed, tags: _targetTagsInput ? _targetTagsInput.getValue() : [], }; diff --git a/server/src/ledgrab/static/js/types/output-target.ts b/server/src/ledgrab/static/js/types/output-target.ts index b76a5e1..23f39a2 100644 --- a/server/src/ledgrab/static/js/types/output-target.ts +++ b/server/src/ledgrab/static/js/types/output-target.ts @@ -50,6 +50,8 @@ export interface LedOutputTarget extends OutputTargetBase { min_brightness_threshold?: BindableFloat; adaptive_fps: boolean; protocol: string; + max_milliamps?: number; + milliamps_per_led?: number; } export type HALightSourceKind = 'css' | 'color_vs'; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 99c5a98..d164971 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -2079,6 +2079,10 @@ "targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.", "targets.protocol": "Protocol:", "targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.", + "targets.power_limit": "Max current (ABL):", + "targets.power_limit.hint": "Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.", + "targets.power_limit.ma_suffix": "mA (0 = unlimited)", + "targets.power_limit.per_led": "mA per LED (full white):", "targets.protocol.ddp": "DDP (UDP)", "targets.protocol.ddp.desc": "Fast raw UDP packets — recommended", "targets.protocol.http": "HTTP", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 744e2af..d1bd9a5 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -1939,6 +1939,10 @@ "targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.", "targets.protocol": "Протокол:", "targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.", + "targets.power_limit": "Макс. ток (ABL):", + "targets.power_limit.hint": "Ограничивает расчётный ток ленты бюджетом блока питания, чтобы избежать просадок напряжения (сдвиг цвета, мерцание, перезагрузки) на ярких/белых сценах. Укажите номинальный ток вашего БП с запасом. 0 = без ограничения.", + "targets.power_limit.ma_suffix": "мА (0 = без ограничения)", + "targets.power_limit.per_led": "мА на светодиод (полный белый):", "targets.protocol.ddp": "DDP (UDP)", "targets.protocol.ddp.desc": "Быстрые UDP-пакеты — рекомендуется", "targets.protocol.http": "HTTP", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 4246415..d951638 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -1935,6 +1935,10 @@ "targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。", "targets.protocol": "协议:", "targets.protocol.hint": "DDP通过快速UDP发送像素(推荐)。HTTP使用JSON API——较慢但可靠,限制约500个LED。", + "targets.power_limit": "最大电流 (ABL):", + "targets.power_limit.hint": "将灯带的估算电流限制在电源预算内,以防止明亮/白色场景下的电压骤降(颜色偏移、闪烁、重启)。请设为电源的额定电流并留有余量。0 = 不限制。", + "targets.power_limit.ma_suffix": "mA(0 = 不限制)", + "targets.power_limit.per_led": "每颗 LED 电流(全白):", "targets.protocol.ddp": "DDP (UDP)", "targets.protocol.ddp.desc": "快速UDP数据包 - 推荐", "targets.protocol.http": "HTTP", diff --git a/server/src/ledgrab/storage/output_target_store.py b/server/src/ledgrab/storage/output_target_store.py index d4bd11b..47b36d5 100644 --- a/server/src/ledgrab/storage/output_target_store.py +++ b/server/src/ledgrab/storage/output_target_store.py @@ -95,6 +95,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold: Any = 0, adaptive_fps: bool = False, protocol: str = "ddp", + max_milliamps: int = 0, + milliamps_per_led: int = 55, description: str | None = None, tags: List[str] | None = None, # legacy compat @@ -116,6 +118,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0), adaptive_fps=adaptive_fps, protocol=protocol, + max_milliamps=max(0, int(max_milliamps or 0)), + milliamps_per_led=max(1, int(milliamps_per_led or 55)), description=description, created_at=now, updated_at=now, @@ -335,6 +339,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold: Any = None, adaptive_fps: bool | None = None, protocol: str | None = None, + max_milliamps: int | None = None, + milliamps_per_led: int | None = None, description: str | None = None, tags: List[str] | None = None, icon: str | None = None, @@ -356,6 +362,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold=min_brightness_threshold, adaptive_fps=adaptive_fps, protocol=protocol, + max_milliamps=max_milliamps, + milliamps_per_led=milliamps_per_led, description=description, tags=tags, icon=icon, diff --git a/server/src/ledgrab/storage/wled_output_target.py b/server/src/ledgrab/storage/wled_output_target.py index 0b15866..31dd8f6 100644 --- a/server/src/ledgrab/storage/wled_output_target.py +++ b/server/src/ledgrab/storage/wled_output_target.py @@ -24,6 +24,11 @@ class WledOutputTarget(OutputTarget, type_key="led"): min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0)) adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API) + # Automatic brightness limiting (ABL): cap estimated strip draw to a PSU + # budget. max_milliamps <= 0 disables it. milliamps_per_led is the full-white + # draw of one LED (WS2812-class default 55 mA). + max_milliamps: int = 0 + milliamps_per_led: int = 55 def register_with_manager(self, manager) -> None: """Register this WLED target with the processor manager.""" @@ -39,6 +44,8 @@ class WledOutputTarget(OutputTarget, type_key="led"): min_brightness_threshold=self.min_brightness_threshold, adaptive_fps=self.adaptive_fps, protocol=self.protocol, + max_milliamps=self.max_milliamps, + milliamps_per_led=self.milliamps_per_led, ) def sync_with_manager( @@ -59,6 +66,8 @@ class WledOutputTarget(OutputTarget, type_key="led"): "state_check_interval": self.state_check_interval, "min_brightness_threshold": self.min_brightness_threshold, "adaptive_fps": self.adaptive_fps, + "max_milliamps": self.max_milliamps, + "milliamps_per_led": self.milliamps_per_led, }, ) if css_changed: @@ -81,6 +90,8 @@ class WledOutputTarget(OutputTarget, type_key="led"): min_brightness_threshold=None, adaptive_fps=None, protocol=None, + max_milliamps=None, + milliamps_per_led=None, description=None, tags: List[str] | None = None, icon: str | None = None, @@ -122,6 +133,10 @@ class WledOutputTarget(OutputTarget, type_key="led"): self.adaptive_fps = adaptive_fps if protocol is not None: self.protocol = protocol + if max_milliamps is not None: + self.max_milliamps = max(0, int(max_milliamps)) + if milliamps_per_led is not None: + self.milliamps_per_led = max(1, int(milliamps_per_led)) @property def has_picture_source(self) -> bool: @@ -139,6 +154,8 @@ class WledOutputTarget(OutputTarget, type_key="led"): d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict() d["adaptive_fps"] = self.adaptive_fps d["protocol"] = self.protocol + d["max_milliamps"] = self.max_milliamps + d["milliamps_per_led"] = self.milliamps_per_led return d @classmethod @@ -165,6 +182,8 @@ class WledOutputTarget(OutputTarget, type_key="led"): ), adaptive_fps=data.get("adaptive_fps", False), protocol=data.get("protocol", "ddp"), + max_milliamps=int(data.get("max_milliamps", 0) or 0), + milliamps_per_led=int(data.get("milliamps_per_led", 55) or 55), description=data.get("description"), tags=data.get("tags", []), icon=data.get("icon", ""), diff --git a/server/src/ledgrab/templates/modals/target-editor.html b/server/src/ledgrab/templates/modals/target-editor.html index fda8e64..0fe4fda 100644 --- a/server/src/ledgrab/templates/modals/target-editor.html +++ b/server/src/ledgrab/templates/modals/target-editor.html @@ -138,6 +138,22 @@ How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s) + +