Backend performance and code quality improvements
Performance (hot path): - Fix double brightness: removed duplicate scaling from 9 device clients (wled, adalight, ambiled, openrgb, hue, spi, chroma, gamesense, usbhid, espnow) — processor loop is now the single source of brightness - Bounded send_timestamps deque with maxlen, removed 3 cleanup loops - Running FPS sum O(1) instead of sum()/len() O(n) per frame - datetime.now(timezone.utc) → time.monotonic() with lazy conversion - Device info refresh interval 30 → 300 iterations - Composite: gate layer_snapshots copy on preview client flag - Composite: versioned sub_streams snapshot (copy only on change) - Composite: pre-resolved blend methods (dict lookup vs getattr) - ApiInput: np.copyto in-place instead of astype allocation Code quality: - BaseJsonStore: RLock on get/delete/get_all/count (was created but unused) - EntityNotFoundError → proper 404 responses across 15 route files - Remove 21 defensive getattr(x,'tags',[]) — field guaranteed on all models - Fix Dict[str,any] → Dict[str,Any] in template/audio_template stores - Log 4 silenced exceptions (automation engine, metrics, system) - ValueStream.get_value() now @abstractmethod - Config.from_yaml: add encoding="utf-8" - OutputTargetStore: remove 25-line _load override, use _legacy_json_keys - BaseJsonStore: add _legacy_json_keys for migration support - Remove unnecessary except Exception→500 from postprocessing list endpoint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -398,8 +398,8 @@ class AutomationEngine:
|
||||
"automation_id": automation_id,
|
||||
"action": action,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("Automation action failed: %s", e, exc_info=True)
|
||||
|
||||
# ===== Public query methods (used by API) =====
|
||||
|
||||
|
||||
@@ -168,9 +168,7 @@ class AdalightClient(LEDClient):
|
||||
else:
|
||||
arr = np.array(pixels, dtype=np.uint16)
|
||||
|
||||
if brightness < 255:
|
||||
arr = arr * brightness // 255
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
np.clip(arr, 0, 255, out=arr)
|
||||
rgb_bytes = arr.astype(np.uint8).tobytes()
|
||||
return self._header + rgb_bytes
|
||||
|
||||
@@ -40,9 +40,7 @@ class AmbiLEDClient(AdalightClient):
|
||||
else:
|
||||
arr = np.array(pixels, dtype=np.uint16)
|
||||
|
||||
if brightness < 255:
|
||||
arr = arr * brightness // 255
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
# Clamp to 0–250: values >250 are command bytes in AmbiLED protocol
|
||||
np.clip(arr, 0, 250, out=arr)
|
||||
rgb_bytes = arr.astype(np.uint8).tobytes()
|
||||
|
||||
@@ -145,7 +145,7 @@ class ChromaClient(LEDClient):
|
||||
else:
|
||||
pixel_arr = np.array(pixels, dtype=np.uint8)
|
||||
|
||||
bri_scale = brightness / 255.0
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
device_info = CHROMA_DEVICES.get(self._chroma_device_type)
|
||||
if not device_info:
|
||||
return False
|
||||
@@ -156,10 +156,7 @@ class ChromaClient(LEDClient):
|
||||
# Chroma uses BGR packed as 0x00BBGGRR integers
|
||||
colors = []
|
||||
for i in range(n):
|
||||
r, g, b = pixel_arr[i]
|
||||
r = int(r * bri_scale)
|
||||
g = int(g * bri_scale)
|
||||
b = int(b * bri_scale)
|
||||
r, g, b = int(pixel_arr[i][0]), int(pixel_arr[i][1]), int(pixel_arr[i][2])
|
||||
colors.append(r | (g << 8) | (b << 16))
|
||||
|
||||
# Pad to max_leds if needed
|
||||
|
||||
@@ -115,7 +115,8 @@ class ESPNowClient(LEDClient):
|
||||
else:
|
||||
pixel_bytes = bytes(c for rgb in pixels for c in rgb)
|
||||
|
||||
frame = _build_frame(self._peer_mac, pixel_bytes, brightness)
|
||||
# Note: brightness already applied by processor loop; pass 255 to firmware
|
||||
frame = _build_frame(self._peer_mac, pixel_bytes, 255)
|
||||
try:
|
||||
self._serial.write(frame)
|
||||
except Exception as e:
|
||||
|
||||
@@ -187,7 +187,7 @@ class GameSenseClient(LEDClient):
|
||||
else:
|
||||
pixel_arr = np.array(pixels, dtype=np.uint8)
|
||||
|
||||
bri_scale = brightness / 255.0
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
|
||||
# Use average color for single-zone devices, or first N for multi-zone
|
||||
if len(pixel_arr) == 0:
|
||||
@@ -195,9 +195,9 @@ class GameSenseClient(LEDClient):
|
||||
|
||||
# Compute average color for the zone
|
||||
avg = pixel_arr.mean(axis=0)
|
||||
r = int(avg[0] * bri_scale)
|
||||
g = int(avg[1] * bri_scale)
|
||||
b = int(avg[2] * bri_scale)
|
||||
r = int(avg[0])
|
||||
g = int(avg[1])
|
||||
b = int(avg[2])
|
||||
|
||||
event_data = {
|
||||
"game": GAME_NAME,
|
||||
|
||||
@@ -46,13 +46,13 @@ def _build_entertainment_frame(
|
||||
header[15] = 0x00 # reserved
|
||||
|
||||
# Light data
|
||||
bri_scale = brightness / 255.0
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
data = bytearray()
|
||||
for idx, (r, g, b) in enumerate(lights):
|
||||
light_id = idx # 0-based light index in entertainment group
|
||||
r16 = int(r * bri_scale * 257) # scale 0-255 to 0-65535
|
||||
g16 = int(g * bri_scale * 257)
|
||||
b16 = int(b * bri_scale * 257)
|
||||
r16 = int(r * 257) # scale 0-255 to 0-65535
|
||||
g16 = int(g * 257)
|
||||
b16 = int(b * 257)
|
||||
data += struct.pack(">BHHH", light_id, r16, g16, b16)
|
||||
|
||||
return bytes(header) + bytes(data)
|
||||
|
||||
@@ -302,9 +302,7 @@ class OpenRGBLEDClient(LEDClient):
|
||||
return
|
||||
self._last_sent_pixels = pixel_array.copy()
|
||||
|
||||
# Apply brightness scaling after dedup
|
||||
if brightness < 255:
|
||||
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
|
||||
# Separate mode: resample full pixel array independently per zone
|
||||
if self._zone_mode == "separate" and len(self._target_zones) > 1:
|
||||
|
||||
@@ -162,7 +162,7 @@ class SPIClient(LEDClient):
|
||||
if not self._connected:
|
||||
return
|
||||
|
||||
bri_scale = brightness / 255.0
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_arr = pixels
|
||||
@@ -176,7 +176,7 @@ class SPIClient(LEDClient):
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
self._strip.setBrightness(brightness)
|
||||
self._strip.setBrightness(255)
|
||||
for i in range(min(len(pixel_arr), self._led_count)):
|
||||
r, g, b = pixel_arr[i]
|
||||
self._strip.setPixelColor(i, Color(int(r), int(g), int(b)))
|
||||
@@ -185,7 +185,7 @@ class SPIClient(LEDClient):
|
||||
elif self._spi:
|
||||
# SPI bitbang path: convert RGB to WS2812 wire format
|
||||
# Each bit is encoded as 3 SPI bits: 1=110, 0=100
|
||||
scaled = (pixel_arr[:self._led_count].astype(np.float32) * bri_scale).astype(np.uint8)
|
||||
scaled = pixel_arr[:self._led_count]
|
||||
# GRB order for WS2812
|
||||
grb = scaled[:, [1, 0, 2]]
|
||||
raw_bytes = grb.tobytes()
|
||||
|
||||
@@ -100,7 +100,7 @@ class USBHIDClient(LEDClient):
|
||||
else:
|
||||
pixel_list = list(pixels)
|
||||
|
||||
bri_scale = brightness / 255.0
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
|
||||
# Build HID reports — split across multiple reports if needed
|
||||
# Each report: [REPORT_ID][CMD][OFFSET_LO][OFFSET_HI][COUNT][R G B R G B ...]
|
||||
@@ -119,9 +119,9 @@ class USBHIDClient(LEDClient):
|
||||
|
||||
for i, (r, g, b) in enumerate(chunk):
|
||||
base = 5 + i * 3
|
||||
report[base] = int(r * bri_scale)
|
||||
report[base + 1] = int(g * bri_scale)
|
||||
report[base + 2] = int(b * bri_scale)
|
||||
report[base] = int(r)
|
||||
report[base + 1] = int(g)
|
||||
report[base + 2] = int(b)
|
||||
|
||||
reports.append(bytes(report))
|
||||
offset += len(chunk)
|
||||
|
||||
@@ -378,9 +378,7 @@ class WLEDClient(LEDClient):
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
if brightness < 255:
|
||||
pixels = (pixels.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
logger.debug(f"Sending {len(pixels)} LEDs via DDP")
|
||||
self._ddp_client.send_pixels_numpy(pixels)
|
||||
logger.debug(f"Successfully sent pixel colors via DDP")
|
||||
@@ -419,7 +417,7 @@ class WLEDClient(LEDClient):
|
||||
# Build WLED JSON state
|
||||
payload = {
|
||||
"on": True,
|
||||
"bri": int(brightness),
|
||||
"bri": 255, # brightness already applied by processor loop
|
||||
"seg": [
|
||||
{
|
||||
"id": segment_id,
|
||||
@@ -461,9 +459,7 @@ class WLEDClient(LEDClient):
|
||||
else:
|
||||
pixel_array = np.array(pixels, dtype=np.uint8)
|
||||
|
||||
if brightness < 255:
|
||||
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
self._ddp_client.send_pixels_numpy(pixel_array)
|
||||
|
||||
# ===== LEDClient abstraction methods =====
|
||||
|
||||
@@ -92,7 +92,11 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
if n > self._led_count:
|
||||
self._ensure_capacity(n)
|
||||
if n == self._led_count:
|
||||
self._colors = colors.astype(np.uint8)
|
||||
if self._colors.shape == colors.shape:
|
||||
np.copyto(self._colors, colors, casting='unsafe')
|
||||
else:
|
||||
self._colors = np.empty((n, 3), dtype=np.uint8)
|
||||
np.copyto(self._colors, colors, casting='unsafe')
|
||||
elif n < self._led_count:
|
||||
# Zero-pad to led_count
|
||||
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
|
||||
|
||||
@@ -48,12 +48,22 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
self._latest_colors: Optional[np.ndarray] = None
|
||||
self._latest_layer_colors: Optional[List[np.ndarray]] = None
|
||||
self._colors_lock = threading.Lock()
|
||||
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
|
||||
|
||||
# layer_index -> (source_id, consumer_id, stream)
|
||||
self._sub_streams: Dict[int, tuple] = {}
|
||||
# layer_index -> (vs_id, value_stream)
|
||||
self._brightness_streams: Dict[int, tuple] = {}
|
||||
self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams
|
||||
self._sub_streams_version: int = 0 # bumped when _sub_streams changes
|
||||
self._sub_snapshot_version: int = -1 # version of cached snapshot
|
||||
self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams)
|
||||
|
||||
# Pre-resolved blend methods: blend_mode_str -> bound method
|
||||
self._blend_methods = {
|
||||
k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()
|
||||
}
|
||||
self._default_blend_method = self._blend_normal
|
||||
|
||||
# Pre-allocated scratch (rebuilt when LED count changes)
|
||||
self._pool_n = 0
|
||||
@@ -111,6 +121,7 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
|
||||
def get_layer_colors(self) -> Optional[List[np.ndarray]]:
|
||||
"""Return per-layer color snapshots (after resize/brightness, before blending)."""
|
||||
self._need_layer_snapshots = True
|
||||
with self._colors_lock:
|
||||
return self._latest_layer_colors
|
||||
|
||||
@@ -165,6 +176,7 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
# ── Sub-stream lifecycle ────────────────────────────────────
|
||||
|
||||
def _acquire_sub_streams(self) -> None:
|
||||
self._sub_streams_version += 1
|
||||
for i, layer in enumerate(self._layers):
|
||||
if not layer.get("enabled", True):
|
||||
continue
|
||||
@@ -193,6 +205,7 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
)
|
||||
|
||||
def _release_sub_streams(self) -> None:
|
||||
self._sub_streams_version += 1
|
||||
for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()):
|
||||
try:
|
||||
self._css_manager.release(src_id, consumer_id)
|
||||
@@ -356,7 +369,10 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
layer_snapshots: List[np.ndarray] = []
|
||||
|
||||
with self._sub_lock:
|
||||
sub_snapshot = dict(self._sub_streams)
|
||||
if self._sub_streams_version != self._sub_snapshot_version:
|
||||
self._sub_snapshot_cache = dict(self._sub_streams)
|
||||
self._sub_snapshot_version = self._sub_streams_version
|
||||
sub_snapshot = self._sub_snapshot_cache
|
||||
|
||||
for i, layer in enumerate(self._layers):
|
||||
if not layer.get("enabled", True):
|
||||
@@ -412,7 +428,8 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
|
||||
|
||||
# Snapshot layer colors before blending (copy — may alias shared buf)
|
||||
layer_snapshots.append(colors.copy())
|
||||
if self._need_layer_snapshots:
|
||||
layer_snapshots.append(colors.copy())
|
||||
|
||||
opacity = layer.get("opacity", 1.0)
|
||||
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
|
||||
@@ -425,11 +442,11 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
result_buf[:] = colors
|
||||
else:
|
||||
result_buf[:] = 0
|
||||
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
|
||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
||||
blend_fn(result_buf, colors, alpha, result_buf)
|
||||
has_result = True
|
||||
else:
|
||||
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
|
||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
||||
blend_fn(result_buf, colors, alpha, result_buf)
|
||||
|
||||
if has_result:
|
||||
|
||||
@@ -91,7 +91,8 @@ class MetricsHistory:
|
||||
# Per-target metrics from processor states
|
||||
try:
|
||||
all_states = self._manager.get_all_target_states()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.error("Failed to get target states: %s", e)
|
||||
all_states = {}
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@@ -43,6 +43,7 @@ class ProcessingMetrics:
|
||||
errors_count: int = 0
|
||||
last_error: Optional[str] = None
|
||||
last_update: Optional[datetime] = None
|
||||
last_update_mono: float = 0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
|
||||
start_time: Optional[datetime] = None
|
||||
fps_actual: float = 0.0
|
||||
fps_potential: float = 0.0
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -43,12 +44,13 @@ logger = get_logger(__name__)
|
||||
# Base class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ValueStream:
|
||||
class ValueStream(ABC):
|
||||
"""Abstract base for runtime value streams."""
|
||||
|
||||
@abstractmethod
|
||||
def get_value(self) -> float:
|
||||
"""Return current scalar value (0.0–1.0)."""
|
||||
return 1.0
|
||||
...
|
||||
|
||||
def start(self) -> None:
|
||||
"""Acquire resources (if any)."""
|
||||
|
||||
@@ -382,6 +382,14 @@ class WledTargetProcessor(TargetProcessor):
|
||||
else:
|
||||
total_ms = None
|
||||
|
||||
# Lazily convert monotonic timestamp to UTC datetime for API
|
||||
last_update = metrics.last_update
|
||||
if metrics.last_update_mono > 0:
|
||||
elapsed = time.monotonic() - metrics.last_update_mono
|
||||
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
|
||||
time.time() - elapsed, tz=timezone.utc
|
||||
)
|
||||
|
||||
return {
|
||||
"target_id": self._target_id,
|
||||
"device_id": self._device_id,
|
||||
@@ -405,7 +413,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
"display_index": self._resolved_display_index,
|
||||
"overlay_active": self._overlay_active,
|
||||
"needs_keepalive": self._needs_keepalive,
|
||||
"last_update": metrics.last_update,
|
||||
"last_update": last_update,
|
||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||
"device_streaming_reachable": self._device_reachable if self._is_running else None,
|
||||
"fps_effective": self._effective_fps if self._is_running else None,
|
||||
@@ -419,6 +427,14 @@ class WledTargetProcessor(TargetProcessor):
|
||||
if metrics.start_time and self._is_running:
|
||||
uptime_seconds = (datetime.now(timezone.utc) - metrics.start_time).total_seconds()
|
||||
|
||||
# Lazily convert monotonic timestamp to UTC datetime for API
|
||||
last_update = metrics.last_update
|
||||
if metrics.last_update_mono > 0:
|
||||
elapsed = time.monotonic() - metrics.last_update_mono
|
||||
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
|
||||
time.time() - elapsed, tz=timezone.utc
|
||||
)
|
||||
|
||||
return {
|
||||
"target_id": self._target_id,
|
||||
"device_id": self._device_id,
|
||||
@@ -429,7 +445,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
"frames_processed": metrics.frames_processed,
|
||||
"errors_count": metrics.errors_count,
|
||||
"last_error": metrics.last_error,
|
||||
"last_update": metrics.last_update,
|
||||
"last_update": last_update,
|
||||
}
|
||||
|
||||
# ----- Overlay -----
|
||||
@@ -578,7 +594,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
keepalive_interval = self._keepalive_interval
|
||||
|
||||
fps_samples: collections.deque = collections.deque(maxlen=10)
|
||||
send_timestamps: collections.deque = collections.deque()
|
||||
_fps_sum = 0.0
|
||||
send_timestamps: collections.deque = collections.deque(maxlen=target_fps + 10)
|
||||
last_send_time = 0.0
|
||||
_last_preview_broadcast = 0.0
|
||||
prev_frame_time_stamp = time.perf_counter()
|
||||
@@ -728,7 +745,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
has_any_frame = False
|
||||
|
||||
_diag_device_info_age += 1
|
||||
if _diag_device_info is None or _diag_device_info_age >= 30:
|
||||
if _diag_device_info is None or _diag_device_info_age >= 300:
|
||||
_diag_device_info = self._ctx.get_device_info(self._device_id)
|
||||
_diag_device_info_age = 0
|
||||
device_info = _diag_device_info
|
||||
@@ -822,8 +839,6 @@ class WledTargetProcessor(TargetProcessor):
|
||||
send_timestamps.append(now)
|
||||
self._metrics.frames_keepalive += 1
|
||||
self._metrics.frames_skipped += 1
|
||||
while send_timestamps and send_timestamps[0] < loop_start - 1.0:
|
||||
send_timestamps.popleft()
|
||||
self._metrics.fps_current = len(send_timestamps)
|
||||
await asyncio.sleep(SKIP_REPOLL)
|
||||
continue
|
||||
@@ -849,8 +864,6 @@ class WledTargetProcessor(TargetProcessor):
|
||||
await self._broadcast_led_preview(send_colors, cur_brightness)
|
||||
_last_preview_broadcast = now
|
||||
self._metrics.frames_skipped += 1
|
||||
while send_timestamps and send_timestamps[0] < now - 1.0:
|
||||
send_timestamps.popleft()
|
||||
self._metrics.fps_current = len(send_timestamps)
|
||||
is_animated = stream.is_animated
|
||||
repoll = SKIP_REPOLL if is_animated else frame_time
|
||||
@@ -888,7 +901,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
self._metrics.timing_send_ms = send_ms
|
||||
self._metrics.frames_processed += 1
|
||||
self._metrics.last_update = datetime.now(timezone.utc)
|
||||
self._metrics.last_update_mono = time.monotonic()
|
||||
|
||||
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
|
||||
logger.info(
|
||||
@@ -900,14 +913,16 @@ class WledTargetProcessor(TargetProcessor):
|
||||
interval = now - prev_frame_time_stamp
|
||||
prev_frame_time_stamp = now
|
||||
if self._metrics.frames_processed > 1:
|
||||
fps_samples.append(1.0 / interval if interval > 0 else 0)
|
||||
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
|
||||
new_fps = 1.0 / interval if interval > 0 else 0
|
||||
if len(fps_samples) == fps_samples.maxlen:
|
||||
_fps_sum -= fps_samples[0]
|
||||
fps_samples.append(new_fps)
|
||||
_fps_sum += new_fps
|
||||
self._metrics.fps_actual = _fps_sum / len(fps_samples)
|
||||
|
||||
processing_time = now - loop_start
|
||||
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
|
||||
|
||||
while send_timestamps and send_timestamps[0] < now - 1.0:
|
||||
send_timestamps.popleft()
|
||||
self._metrics.fps_current = len(send_timestamps)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user